@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.
package/src/plugin.ts ADDED
@@ -0,0 +1,559 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import { isHookName, type HOOKS, type HookName } from './hooks'
4
+ import type { HookContext, HookValue } from './payloads'
5
+ import { isPluginRegion, type PluginRegion, type PluginRegionContext } from './regions'
6
+ import type { PluginData, PluginGrants, PluginNotify, PluginUsers } from './runtime'
7
+
8
+ export type FilterHandler<K extends HookName> = (
9
+ value: HookValue<K>,
10
+ context: HookContext<K>,
11
+ ) => HookValue<K> | Promise<HookValue<K>>
12
+
13
+ export type EventHandler<K extends HookName> = (
14
+ value: HookValue<K>,
15
+ context: HookContext<K>,
16
+ ) => void | Promise<void>
17
+
18
+ export type HookHandler<K extends HookName> = (typeof HOOKS)[K]['kind'] extends 'filter'
19
+ ? FilterHandler<K>
20
+ : EventHandler<K>
21
+
22
+ export interface HookRegistration<K extends HookName> {
23
+ readonly handler: HookHandler<K>
24
+ readonly priority?: number | undefined
25
+ }
26
+
27
+ export type PluginHooks = {
28
+ readonly [K in HookName]?: HookHandler<K> | HookRegistration<K>
29
+ }
30
+
31
+ export type PluginSettingType = 'string' | 'secret' | 'number' | 'boolean' | 'select'
32
+
33
+ export interface PluginSetting {
34
+ readonly key: string
35
+ readonly label: string
36
+ readonly description?: string | undefined
37
+ readonly type?: PluginSettingType | undefined
38
+ readonly options?: readonly { readonly value: string; readonly label: string }[] | undefined
39
+ readonly env?: string | undefined
40
+ readonly required?: boolean | undefined
41
+ readonly default: string | number | boolean
42
+ readonly advanced?: boolean | undefined
43
+ }
44
+
45
+ export interface PluginMigration {
46
+ readonly id: string
47
+ readonly statements: readonly string[]
48
+ }
49
+
50
+ export interface PluginTask {
51
+ readonly id: string
52
+ readonly intervalSeconds: number
53
+ readonly run: (context: PluginRuntimeContext) => Promise<void> | void
54
+ }
55
+
56
+ export interface PluginAdminPage {
57
+ readonly path: string
58
+ readonly title: string
59
+ readonly render: (context: PluginAdminPageContext) => ReactNode | Promise<ReactNode>
60
+ }
61
+
62
+ export interface PluginContribution {
63
+ readonly region: PluginRegion
64
+ readonly priority?: number | undefined
65
+ readonly render: (context: PluginRegionContext) => ReactNode
66
+ }
67
+
68
+ export interface PluginViewer {
69
+ readonly userId: number | null
70
+ readonly isGuest: boolean
71
+ }
72
+
73
+ export interface PluginRequest {
74
+ readonly viewer: PluginViewer
75
+ readonly method: 'GET' | 'POST'
76
+ readonly path: string
77
+ readonly query: Readonly<Record<string, string>>
78
+ readonly headers: Readonly<Record<string, string>>
79
+ readonly rawBody: Uint8Array | null
80
+ readonly form: Readonly<Record<string, string>> | null
81
+ readonly json: unknown
82
+ readonly boardUrl: string
83
+ }
84
+
85
+ export type PluginResponse =
86
+ | { readonly kind: 'json'; readonly status?: number | undefined; readonly body: unknown }
87
+ | {
88
+ readonly kind: 'text'
89
+ readonly status?: number | undefined
90
+ readonly body: string
91
+ readonly contentType?: string | undefined
92
+ }
93
+ | { readonly kind: 'redirect'; readonly to: string }
94
+
95
+ export type PluginRouteAccess = 'anonymous' | 'member' | 'admin'
96
+ export type PluginPageAccess = 'anonymous' | 'member'
97
+
98
+ export interface PluginRouteRateLimit {
99
+ readonly limit: number
100
+ readonly windowSeconds: number
101
+ }
102
+
103
+ export interface PluginRoute {
104
+ readonly path: string
105
+ readonly method: 'GET' | 'POST'
106
+ readonly access: PluginRouteAccess
107
+ readonly rawBody?: boolean | undefined
108
+ readonly maxBodyBytes?: number | undefined
109
+ readonly rateLimit?: PluginRouteRateLimit | undefined
110
+ readonly handler: (
111
+ request: PluginRequest,
112
+ context: PluginRuntimeContext,
113
+ ) => Promise<PluginResponse> | PluginResponse
114
+ }
115
+
116
+ export interface PluginPageContext extends PluginRuntimeContext {
117
+ readonly viewer: PluginViewer
118
+ readonly path: string
119
+ readonly query: Readonly<Record<string, string>>
120
+ readonly boardUrl: string
121
+ }
122
+
123
+ export interface PluginAdminPageContext extends PluginRuntimeContext {
124
+ readonly query: Readonly<Record<string, string>>
125
+ }
126
+
127
+ export interface PluginBoardPage {
128
+ readonly path: string
129
+ readonly title: string
130
+ readonly access: PluginPageAccess
131
+ readonly render: (context: PluginPageContext) => ReactNode | Promise<ReactNode>
132
+ }
133
+
134
+ export interface PluginRuntimeContext {
135
+ readonly settings: Readonly<Record<string, string | number | boolean>>
136
+ readonly logger: {
137
+ readonly info: (message: string, detail?: Record<string, unknown>) => void
138
+ readonly warn: (message: string, detail?: Record<string, unknown>) => void
139
+ readonly error: (message: string, detail?: Record<string, unknown>) => void
140
+ }
141
+ readonly grants: PluginGrants
142
+ readonly data: PluginData
143
+ readonly users: PluginUsers
144
+ readonly notify: PluginNotify
145
+ }
146
+
147
+ export interface PluginNotificationKind {
148
+ readonly key: string
149
+ readonly title: string
150
+ readonly description: string
151
+ readonly emailByDefault?: boolean | undefined
152
+ }
153
+
154
+ export interface PluginDefinition {
155
+ readonly key: string
156
+ readonly name: string
157
+ readonly version: string
158
+ readonly description?: string | undefined
159
+ readonly apiVersion?: string | undefined
160
+
161
+ readonly dependsOn?: readonly string[] | undefined
162
+
163
+ readonly hooks?: PluginHooks | undefined
164
+ readonly settings?: readonly PluginSetting[] | undefined
165
+ readonly migrations?: readonly PluginMigration[] | undefined
166
+ readonly tasks?: readonly PluginTask[] | undefined
167
+ readonly adminPages?: readonly PluginAdminPage[] | undefined
168
+ readonly contributions?: readonly PluginContribution[] | undefined
169
+ readonly routes?: readonly PluginRoute[] | undefined
170
+ readonly pages?: readonly PluginBoardPage[] | undefined
171
+ readonly notifications?: readonly PluginNotificationKind[] | undefined
172
+ readonly allowedRedirectHosts?: readonly string[] | undefined
173
+
174
+ readonly onInstall?: ((context: PluginRuntimeContext) => Promise<void> | void) | undefined
175
+ readonly onEnable?: ((context: PluginRuntimeContext) => Promise<void> | void) | undefined
176
+ readonly onDisable?: ((context: PluginRuntimeContext) => Promise<void> | void) | undefined
177
+ readonly onUninstall?: ((context: PluginRuntimeContext) => Promise<void> | void) | undefined
178
+ }
179
+
180
+ const KEY_PATTERN = /^[a-z][a-z0-9-]{1,39}$/
181
+ const SETTING_KEY_PATTERN = /^[a-z][a-z0-9_]{1,39}$/
182
+ const MIGRATION_ID_PATTERN = /^\d{4}_[a-z0-9_]{1,60}$/
183
+ const TASK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/
184
+ const PAGE_PATH_PATTERN = /^[a-z][a-z0-9-]{0,39}$/
185
+ const ROUTE_PATH_PATTERN = /^[a-z][a-z0-9-]{0,39}(\/[a-z0-9-]{1,40}){0,3}$/
186
+ const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{2,63}$/
187
+ const REDIRECT_HOST_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/
188
+
189
+ export const MAX_ROUTE_BODY_BYTES = 1_048_576
190
+ export const DEFAULT_ROUTE_BODY_BYTES = 65_536
191
+
192
+ export function pluginTablePrefix(pluginKey: string): string {
193
+ return `plugin_${pluginKey.replace(/-/g, '_')}_`
194
+ }
195
+
196
+ const MIGRATION_FORMS: readonly {
197
+ readonly pattern: RegExp
198
+ readonly describe: string
199
+ }[] = [
200
+ { pattern: /^create\s+table(?:\s+if\s+not\s+exists)?\s+(\S+)/i, describe: 'create table' },
201
+ { pattern: /^alter\s+table(?:\s+if\s+exists)?(?:\s+only)?\s+(\S+)/i, describe: 'alter table' },
202
+ { pattern: /^drop\s+table(?:\s+if\s+exists)?\s+(\S+)/i, describe: 'drop table' },
203
+ {
204
+ pattern:
205
+ /^create\s+(?:unique\s+)?index(?:\s+if\s+not\s+exists)?\s+(\S+)\s+on\s+(\S+)/i,
206
+ describe: 'create index',
207
+ },
208
+ { pattern: /^drop\s+index(?:\s+if\s+exists)?\s+(\S+)/i, describe: 'drop index' },
209
+ { pattern: /^create\s+sequence(?:\s+if\s+not\s+exists)?\s+(\S+)/i, describe: 'create sequence' },
210
+ { pattern: /^create\s+(?:or\s+replace\s+)?view\s+(\S+)/i, describe: 'create view' },
211
+ { pattern: /^insert\s+into\s+(\S+)/i, describe: 'insert into' },
212
+ { pattern: /^update\s+(\S+)/i, describe: 'update' },
213
+ { pattern: /^delete\s+from\s+(\S+)/i, describe: 'delete from' },
214
+ { pattern: /^truncate(?:\s+table)?\s+(\S+)/i, describe: 'truncate' },
215
+ { pattern: /^comment\s+on\s+(?:table|column|index|view)\s+(\S+)/i, describe: 'comment on' },
216
+ ]
217
+
218
+ function bareIdentifier(raw: string): string {
219
+ let name = raw.replace(/[(;,].*$/s, '').replace(/"/g, '').toLowerCase()
220
+ if (name.startsWith('public.')) name = name.slice('public.'.length)
221
+ const dot = name.indexOf('.')
222
+ return dot === -1 ? name : name.slice(0, dot)
223
+ }
224
+
225
+ function assertMigrationStatement(where: string, prefix: string, statement: string): void {
226
+ const trimmed = statement.trim()
227
+
228
+ const form = MIGRATION_FORMS.map((candidate) => ({
229
+ candidate,
230
+ match: trimmed.match(candidate.pattern),
231
+ })).find((entry) => entry.match !== null)
232
+
233
+ if (form === undefined || form.match === null) {
234
+ throw new Error(
235
+ `${where}: migration statement "${trimmed.slice(0, 60)}…" is not a form plugin ` +
236
+ `migrations may use. Migrations create and fill this plugin's own ${prefix}* ` +
237
+ 'objects; anything else belongs to core or to a query at runtime.',
238
+ )
239
+ }
240
+
241
+ for (const raw of form.match.slice(1)) {
242
+ const name = bareIdentifier(raw)
243
+ if (!name.startsWith(prefix)) {
244
+ throw new Error(
245
+ `${where}: ${form.candidate.describe} "${name}" is outside this plugin's namespace. ` +
246
+ `Every object a plugin's migrations touch must be named ${prefix}* — that is what ` +
247
+ 'lets two plugins coexist and keeps either out of the board’s own tables.',
248
+ )
249
+ }
250
+ }
251
+
252
+ for (const match of trimmed.matchAll(/references\s+(\S+)/gi)) {
253
+ const target = bareIdentifier(match[1] as string)
254
+ if (!target.startsWith(prefix)) {
255
+ throw new Error(
256
+ `${where}: a foreign key to "${target}" reaches outside this plugin's namespace. ` +
257
+ 'Store the id as a plain column instead — a plugin table must not couple itself ' +
258
+ 'to the board’s schema.',
259
+ )
260
+ }
261
+ }
262
+ }
263
+
264
+ export function definePlugin(plugin: PluginDefinition): PluginDefinition {
265
+ const where = `definePlugin("${plugin.key}")`
266
+
267
+ if (!KEY_PATTERN.test(plugin.key)) {
268
+ throw new Error(
269
+ `definePlugin: "${plugin.key}" is not a valid plugin key. Use lower-case letters, ` +
270
+ 'digits and hyphens — it namespaces this plugin’s settings, tasks and admin routes.',
271
+ )
272
+ }
273
+ if (plugin.name.trim() === '') throw new Error(`${where}: name must not be empty.`)
274
+ if (!/^\d+\.\d+\.\d+$/.test(plugin.version)) {
275
+ throw new Error(`${where}: version must be semver (major.minor.patch), got "${plugin.version}".`)
276
+ }
277
+
278
+ for (const dependency of plugin.dependsOn ?? []) {
279
+ if (!KEY_PATTERN.test(dependency)) {
280
+ throw new Error(`${where}: "${dependency}" is not a valid plugin key to depend on.`)
281
+ }
282
+ if (dependency === plugin.key) {
283
+ throw new Error(`${where}: a plugin cannot depend on itself.`)
284
+ }
285
+ }
286
+
287
+ for (const [name, registration] of Object.entries(plugin.hooks ?? {})) {
288
+ if (!isHookName(name)) {
289
+ throw new Error(
290
+ `${where}: unknown hook "${name}". A misspelled hook is a handler that never ` +
291
+ 'runs, which looks exactly like a plugin that installs cleanly and does nothing.',
292
+ )
293
+ }
294
+ const handler =
295
+ typeof registration === 'function' ? registration : (registration as HookRegistration<HookName>)?.handler
296
+ if (typeof handler !== 'function') {
297
+ throw new Error(`${where}: hook "${name}" must be a function or { handler, priority }.`)
298
+ }
299
+ }
300
+
301
+ assertUnique(where, 'setting', (plugin.settings ?? []).map((setting) => setting.key))
302
+ for (const setting of plugin.settings ?? []) {
303
+ if (!SETTING_KEY_PATTERN.test(setting.key)) {
304
+ throw new Error(
305
+ `${where}: setting key "${setting.key}" must be lower-case letters, digits and ` +
306
+ 'underscores. It becomes plugin.<plugin>.<key> in the settings registry.',
307
+ )
308
+ }
309
+
310
+ const declared = setting.type
311
+ if (declared !== undefined) {
312
+ const expected: PluginSettingType[] =
313
+ typeof setting.default === 'boolean'
314
+ ? ['boolean']
315
+ : typeof setting.default === 'number'
316
+ ? ['number']
317
+ : ['string', 'secret', 'select']
318
+ if (!expected.includes(declared)) {
319
+ throw new Error(
320
+ `${where}: setting "${setting.key}" declares type "${declared}" but its default is ` +
321
+ `a ${typeof setting.default}. The default is what an unset board runs on; the two ` +
322
+ 'cannot disagree.',
323
+ )
324
+ }
325
+ }
326
+
327
+ if (declared === 'secret' && setting.default !== '') {
328
+ throw new Error(
329
+ `${where}: secret setting "${setting.key}" must default to "". A shipped secret is ` +
330
+ 'not a secret, and a working fallback credential is a credential in the repository.',
331
+ )
332
+ }
333
+
334
+ if (declared === 'select') {
335
+ const options = setting.options ?? []
336
+ if (options.length === 0) {
337
+ throw new Error(`${where}: select setting "${setting.key}" needs options.`)
338
+ }
339
+ if (!options.some((option) => option.value === setting.default)) {
340
+ throw new Error(
341
+ `${where}: select setting "${setting.key}" defaults to "${String(setting.default)}", ` +
342
+ 'which is not one of its options.',
343
+ )
344
+ }
345
+ }
346
+
347
+ if (setting.env !== undefined && !ENV_NAME_PATTERN.test(setting.env)) {
348
+ throw new Error(
349
+ `${where}: setting "${setting.key}" names the environment variable "${setting.env}". ` +
350
+ 'Use upper-case letters, digits and underscores, like DUES_STRIPE_SECRET_KEY.',
351
+ )
352
+ }
353
+ }
354
+
355
+ assertUnique(where, 'migration', (plugin.migrations ?? []).map((migration) => migration.id))
356
+ const migrationIds = (plugin.migrations ?? []).map((migration) => migration.id)
357
+ for (const id of migrationIds) {
358
+ if (!MIGRATION_ID_PATTERN.test(id)) {
359
+ throw new Error(
360
+ `${where}: migration id "${id}" must look like 0001_description. Ids are applied ` +
361
+ 'in sort order, so they have to sort the way they were written.',
362
+ )
363
+ }
364
+ }
365
+ const sorted = [...migrationIds].sort()
366
+ if (migrationIds.some((id, index) => id !== sorted[index])) {
367
+ throw new Error(
368
+ `${where}: migrations are not in ascending id order (${migrationIds.join(', ')}). ` +
369
+ 'They are applied in sort order, so a list that reads differently from the order ' +
370
+ 'it runs in is a schema that differs between a fresh board and an upgraded one.',
371
+ )
372
+ }
373
+ for (const migration of plugin.migrations ?? []) {
374
+ if (migration.statements.length === 0) {
375
+ throw new Error(`${where}: migration "${migration.id}" has no statements.`)
376
+ }
377
+ for (const statement of migration.statements) {
378
+ assertMigrationStatement(
379
+ `${where}, migration "${migration.id}"`,
380
+ pluginTablePrefix(plugin.key),
381
+ statement,
382
+ )
383
+ }
384
+ }
385
+
386
+ assertUnique(where, 'task', (plugin.tasks ?? []).map((task) => task.id))
387
+ for (const task of plugin.tasks ?? []) {
388
+ if (!TASK_ID_PATTERN.test(task.id)) {
389
+ throw new Error(`${where}: task id "${task.id}" must be lower-case letters, digits and hyphens.`)
390
+ }
391
+ if (!Number.isInteger(task.intervalSeconds) || task.intervalSeconds < 60) {
392
+ throw new Error(
393
+ `${where}: task "${task.id}" has an interval of ${task.intervalSeconds}s. The tick ` +
394
+ 'is minute-granular at best on a serverless platform; anything under 60s is a ' +
395
+ 'task that claims a frequency the scheduler cannot deliver.',
396
+ )
397
+ }
398
+ }
399
+
400
+ assertUnique(where, 'admin page', (plugin.adminPages ?? []).map((page) => page.path))
401
+ for (const page of plugin.adminPages ?? []) {
402
+ if (!PAGE_PATH_PATTERN.test(page.path)) {
403
+ throw new Error(
404
+ `${where}: admin page path "${page.path}" must be a single lower-case segment. ` +
405
+ 'Pages are mounted under /admin/plugins/<plugin>/<path>; a slash here would ' +
406
+ 'escape that prefix.',
407
+ )
408
+ }
409
+ }
410
+
411
+ for (const contribution of plugin.contributions ?? []) {
412
+ if (!isPluginRegion(contribution.region)) {
413
+ throw new Error(`${where}: unknown UI region "${contribution.region}".`)
414
+ }
415
+ if (typeof contribution.render !== 'function') {
416
+ throw new Error(`${where}: contribution to "${contribution.region}" needs a render function.`)
417
+ }
418
+ }
419
+
420
+ assertUnique(
421
+ where,
422
+ 'route',
423
+ (plugin.routes ?? []).map((route) => `${route.method} ${route.path}`),
424
+ )
425
+ for (const route of plugin.routes ?? []) {
426
+ if (!ROUTE_PATH_PATTERN.test(route.path)) {
427
+ throw new Error(
428
+ `${where}: route path "${route.path}" must be one to four lower-case segments, ` +
429
+ 'like "checkout" or "hook/stripe". Routes are mounted under ' +
430
+ '/api/plugins/<plugin>/<path>; anything else would escape that prefix.',
431
+ )
432
+ }
433
+ if (route.method !== 'GET' && route.method !== 'POST') {
434
+ throw new Error(`${where}: route "${route.path}" method must be GET or POST.`)
435
+ }
436
+ if (route.access !== 'anonymous' && route.access !== 'member' && route.access !== 'admin') {
437
+ throw new Error(
438
+ `${where}: route "${route.path}" access must be "anonymous", "member" or "admin".`,
439
+ )
440
+ }
441
+ if (typeof route.handler !== 'function') {
442
+ throw new Error(`${where}: route "${route.path}" needs a handler function.`)
443
+ }
444
+ if (route.maxBodyBytes !== undefined) {
445
+ if (
446
+ !Number.isInteger(route.maxBodyBytes) ||
447
+ route.maxBodyBytes < 1 ||
448
+ route.maxBodyBytes > MAX_ROUTE_BODY_BYTES
449
+ ) {
450
+ throw new Error(
451
+ `${where}: route "${route.path}" maxBodyBytes must be between 1 and ` +
452
+ `${MAX_ROUTE_BODY_BYTES}. A bigger payload belongs in an attachment, not a route body.`,
453
+ )
454
+ }
455
+ }
456
+ if (route.rateLimit !== undefined) {
457
+ const { limit, windowSeconds } = route.rateLimit
458
+ if (!Number.isInteger(limit) || limit < 1 || limit > 10_000) {
459
+ throw new Error(
460
+ `${where}: route "${route.path}" rateLimit.limit must be a whole number ` +
461
+ 'between 1 and 10000.',
462
+ )
463
+ }
464
+ if (!Number.isInteger(windowSeconds) || windowSeconds < 1 || windowSeconds > 3_600) {
465
+ throw new Error(
466
+ `${where}: route "${route.path}" rateLimit.windowSeconds must be a whole ` +
467
+ 'number of seconds, up to an hour.',
468
+ )
469
+ }
470
+ }
471
+ }
472
+
473
+ assertUnique(where, 'page', (plugin.pages ?? []).map((page) => page.path))
474
+ for (const page of plugin.pages ?? []) {
475
+ if (page.path !== '' && !PAGE_PATH_PATTERN.test(page.path)) {
476
+ throw new Error(
477
+ `${where}: page path "${page.path}" must be empty (the plugin's index page) or a ` +
478
+ 'single lower-case segment. Pages are mounted under /plugins/<plugin>/<path>.',
479
+ )
480
+ }
481
+ if (page.title.trim() === '') {
482
+ throw new Error(`${where}: the page at "${page.path}" needs a title.`)
483
+ }
484
+ if (page.access !== 'anonymous' && page.access !== 'member') {
485
+ throw new Error(`${where}: page "${page.path}" access must be "anonymous" or "member".`)
486
+ }
487
+ if (typeof page.render !== 'function') {
488
+ throw new Error(`${where}: page "${page.path}" needs a render function.`)
489
+ }
490
+ }
491
+
492
+ assertUnique(
493
+ where,
494
+ 'notification kind',
495
+ (plugin.notifications ?? []).map((kind) => kind.key),
496
+ )
497
+ for (const kind of plugin.notifications ?? []) {
498
+ if (!SETTING_KEY_PATTERN.test(kind.key)) {
499
+ throw new Error(
500
+ `${where}: notification kind "${kind.key}" must be lower-case letters, digits ` +
501
+ 'and underscores. It becomes plugin.<plugin>.<kind> in the registry, and a ' +
502
+ 'line on every member’s notification preferences screen.',
503
+ )
504
+ }
505
+ if (kind.title.trim() === '' || kind.description.trim() === '') {
506
+ throw new Error(
507
+ `${where}: notification kind "${kind.key}" needs a title and a description — ` +
508
+ 'they are what the member reads when deciding whether to get emails for it.',
509
+ )
510
+ }
511
+ }
512
+
513
+ assertUnique(where, 'redirect host', plugin.allowedRedirectHosts ?? [])
514
+ for (const host of plugin.allowedRedirectHosts ?? []) {
515
+ if (!REDIRECT_HOST_PATTERN.test(host)) {
516
+ throw new Error(
517
+ `${where}: "${host}" is not a plain host name. List bare hosts like ` +
518
+ '"checkout.stripe.com" — no scheme, no path, no port.',
519
+ )
520
+ }
521
+ }
522
+
523
+ return plugin
524
+ }
525
+
526
+ function assertUnique(where: string, kind: string, values: readonly string[]): void {
527
+ const duplicate = values.find((value, index) => values.indexOf(value) !== index)
528
+ if (duplicate !== undefined) {
529
+ throw new Error(`${where}: ${kind} "${duplicate}" is declared twice.`)
530
+ }
531
+ }
532
+
533
+ export function pluginSettingKey(pluginKey: string, settingKey: string): string {
534
+ return `plugin.${pluginKey}.${settingKey}`
535
+ }
536
+
537
+ export function pluginNotificationKindId(pluginKey: string, kindKey: string): string {
538
+ return `plugin.${pluginKey}.${kindKey}`
539
+ }
540
+
541
+ export function pluginTaskId(pluginKey: string, taskId: string): string {
542
+ return `plugin.${pluginKey}.${taskId}`
543
+ }
544
+
545
+ export function pluginAdminPath(pluginKey: string, path: string): string {
546
+ return `/admin/plugins/${pluginKey}${path === '' ? '' : `/${path}`}`
547
+ }
548
+
549
+ export function pluginRoutePath(pluginKey: string, path: string): string {
550
+ return `/api/plugins/${pluginKey}/${path}`
551
+ }
552
+
553
+ export function pluginAdminRoutePath(pluginKey: string, path: string): string {
554
+ return `/admin/api/plugins/${pluginKey}/${path}`
555
+ }
556
+
557
+ export function pluginPagePath(pluginKey: string, path: string): string {
558
+ return `/plugins/${pluginKey}${path === '' ? '' : `/${path}`}`
559
+ }
@@ -0,0 +1,42 @@
1
+ export interface RateLimitVerdict {
2
+ readonly allowed: boolean
3
+ readonly retryAfterSeconds: number
4
+ }
5
+
6
+ export interface RouteRateLimiter {
7
+ consume(key: string, limit: number, windowSeconds: number, nowMs: number): RateLimitVerdict
8
+ }
9
+
10
+ const SWEEP_EVERY = 512
11
+
12
+ export function createRouteRateLimiter(): RouteRateLimiter {
13
+ const windows = new Map<string, { start: number; count: number }>()
14
+ let calls = 0
15
+
16
+ return {
17
+ consume(key, limit, windowSeconds, nowMs) {
18
+ calls += 1
19
+ const windowMs = windowSeconds * 1000
20
+
21
+ if (calls % SWEEP_EVERY === 0) {
22
+ for (const [stale, entry] of windows) {
23
+ if (nowMs - entry.start >= windowMs) windows.delete(stale)
24
+ }
25
+ }
26
+
27
+ const entry = windows.get(key)
28
+ if (entry === undefined || nowMs - entry.start >= windowMs) {
29
+ windows.set(key, { start: nowMs, count: 1 })
30
+ return { allowed: true, retryAfterSeconds: 0 }
31
+ }
32
+
33
+ entry.count += 1
34
+ if (entry.count <= limit) return { allowed: true, retryAfterSeconds: 0 }
35
+
36
+ return {
37
+ allowed: false,
38
+ retryAfterSeconds: Math.max(1, Math.ceil((entry.start + windowMs - nowMs) / 1000)),
39
+ }
40
+ },
41
+ }
42
+ }
package/src/regions.ts ADDED
@@ -0,0 +1,48 @@
1
+ export interface RegionSpec {
2
+ readonly purpose: string
3
+ readonly context: string
4
+ }
5
+
6
+ export const PLUGIN_REGIONS = {
7
+ 'header.notice': {
8
+ purpose: 'Directly below the board header, above the page body. Board-wide notices.',
9
+ context: 'The viewer.',
10
+ },
11
+ 'index.footer': {
12
+ purpose: 'The bottom of the board index, below the statistics block.',
13
+ context: 'The viewer.',
14
+ },
15
+ 'postbit.badges': {
16
+ purpose:
17
+ 'Beside a post author’s name. Runs once per post on every thread page — the ' +
18
+ 'most expensive region on the board, and the one to keep trivial.',
19
+ context: 'The viewer, the post id and the author id.',
20
+ },
21
+ 'postbit.footer': {
22
+ purpose: 'Below a post body, above its actions.',
23
+ context: 'The viewer, the post id and the author id.',
24
+ },
25
+ 'profile.panel': {
26
+ purpose: 'A panel on a member’s profile, below the standard fields.',
27
+ context: 'The viewer and the profile’s member id.',
28
+ },
29
+ 'admin.dashboard': {
30
+ purpose: 'A card on the admin dashboard. Only rendered for administrators.',
31
+ context: 'The viewer.',
32
+ },
33
+ } as const satisfies Readonly<Record<string, RegionSpec>>
34
+
35
+ export type PluginRegion = keyof typeof PLUGIN_REGIONS
36
+
37
+ export const REGION_NAMES = Object.keys(PLUGIN_REGIONS) as readonly PluginRegion[]
38
+
39
+ export function isPluginRegion(value: string): value is PluginRegion {
40
+ return Object.hasOwn(PLUGIN_REGIONS, value)
41
+ }
42
+
43
+ export interface PluginRegionContext {
44
+ readonly region: PluginRegion
45
+ readonly viewer: { readonly userId: number | null; readonly isGuest: boolean }
46
+ readonly subjectId: number | null
47
+ readonly authorId: number | null
48
+ }