@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/LICENSE.md +165 -0
- package/package.json +31 -0
- package/src/hooks.ts +497 -0
- package/src/host.ts +283 -0
- package/src/index.ts +118 -0
- package/src/payloads.ts +414 -0
- package/src/plugin.ts +559 -0
- package/src/rate-limit.ts +42 -0
- package/src/regions.ts +48 -0
- package/src/runtime.ts +187 -0
- package/src/settings.ts +131 -0
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
|
+
}
|