@meith/plugin-kit 0.31.0 → 0.32.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/package.json +3 -3
- package/src/host.ts +50 -5
- package/src/index.ts +8 -0
- package/src/navigation.ts +4 -1
- package/src/plugin.ts +67 -12
- package/src/regions.ts +39 -7
- package/src/runtime.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/plugin-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "The SDK for writing a Meith plugin: typed manifests, hooks, routes, pages and migrations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"access": "public"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@meith/core": "^0.
|
|
24
|
-
"@meith/theme-kit": "^0.
|
|
23
|
+
"@meith/core": "^0.32.0",
|
|
24
|
+
"@meith/theme-kit": "^0.32.0"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
27
|
"react": "^19.2.0"
|
package/src/host.ts
CHANGED
|
@@ -5,11 +5,12 @@ import type { HookContext, HookValue } from './payloads'
|
|
|
5
5
|
import type {
|
|
6
6
|
HookRegistration,
|
|
7
7
|
HookRuntime,
|
|
8
|
-
PluginContribution,
|
|
9
8
|
PluginDefinition,
|
|
9
|
+
PluginRegionContribution,
|
|
10
10
|
PluginRuntimeContext,
|
|
11
|
+
ThreadRowBadgesContribution,
|
|
11
12
|
} from './plugin'
|
|
12
|
-
import type { PluginRegion, PluginRegionContext } from './regions'
|
|
13
|
+
import type { PluginRegion, PluginRegionContext, ThreadRowBadgesContext } from './regions'
|
|
13
14
|
|
|
14
15
|
export interface HostLogger {
|
|
15
16
|
readonly warn: (message: string, detail: Record<string, unknown>) => void
|
|
@@ -83,8 +84,13 @@ export class PluginHost {
|
|
|
83
84
|
readonly #entries = new Map<HookName, Entry[]>()
|
|
84
85
|
readonly #contributions = new Map<
|
|
85
86
|
PluginRegion,
|
|
86
|
-
{ pluginKey: string; priority: number;
|
|
87
|
+
{ pluginKey: string; priority: number; render: PluginRegionContribution['render'] }[]
|
|
87
88
|
>()
|
|
89
|
+
readonly #badgeContributions: {
|
|
90
|
+
pluginKey: string
|
|
91
|
+
priority: number
|
|
92
|
+
render: ThreadRowBadgesContribution['render']
|
|
93
|
+
}[] = []
|
|
88
94
|
readonly #stats = new Map<string, Stats>()
|
|
89
95
|
readonly #logger: HostLogger
|
|
90
96
|
readonly #failureThreshold: number
|
|
@@ -140,11 +146,20 @@ export class PluginHost {
|
|
|
140
146
|
}
|
|
141
147
|
|
|
142
148
|
for (const contribution of plugin.contributions ?? []) {
|
|
149
|
+
if (contribution.region === 'threadrow.badges') {
|
|
150
|
+
this.#badgeContributions.push({
|
|
151
|
+
pluginKey: plugin.key,
|
|
152
|
+
priority: contribution.priority ?? DEFAULT_PRIORITY,
|
|
153
|
+
render: contribution.render,
|
|
154
|
+
})
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
|
|
143
158
|
const list = this.#contributions.get(contribution.region) ?? []
|
|
144
159
|
list.push({
|
|
145
160
|
pluginKey: plugin.key,
|
|
146
161
|
priority: contribution.priority ?? DEFAULT_PRIORITY,
|
|
147
|
-
contribution,
|
|
162
|
+
render: contribution.render,
|
|
148
163
|
})
|
|
149
164
|
this.#contributions.set(contribution.region, list)
|
|
150
165
|
}
|
|
@@ -159,6 +174,7 @@ export class PluginHost {
|
|
|
159
174
|
|
|
160
175
|
for (const list of this.#entries.values()) list.sort(byPriorityThenKey)
|
|
161
176
|
for (const list of this.#contributions.values()) list.sort(byPriorityThenKey)
|
|
177
|
+
this.#badgeContributions.sort(byPriorityThenKey)
|
|
162
178
|
}
|
|
163
179
|
|
|
164
180
|
async applyFilter<K extends HookName>(
|
|
@@ -219,7 +235,7 @@ export class PluginHost {
|
|
|
219
235
|
|
|
220
236
|
const started = this.#now()
|
|
221
237
|
try {
|
|
222
|
-
const node = await entry.
|
|
238
|
+
const node = await entry.render({
|
|
223
239
|
...context,
|
|
224
240
|
runtime: this.#runtimeFor(entry.pluginKey),
|
|
225
241
|
})
|
|
@@ -232,6 +248,35 @@ export class PluginHost {
|
|
|
232
248
|
return nodes
|
|
233
249
|
}
|
|
234
250
|
|
|
251
|
+
async renderThreadRowBadges(
|
|
252
|
+
context: Omit<ThreadRowBadgesContext, 'runtime'>,
|
|
253
|
+
): Promise<ReadonlyMap<number, readonly { key: string; node: ReactNode }[]>> {
|
|
254
|
+
const byThread = new Map<number, { key: string; node: ReactNode }[]>()
|
|
255
|
+
if (this.#badgeContributions.length === 0) return byThread
|
|
256
|
+
|
|
257
|
+
for (const entry of this.#badgeContributions) {
|
|
258
|
+
if (!this.#isEnabled(entry.pluginKey)) continue
|
|
259
|
+
|
|
260
|
+
const started = this.#now()
|
|
261
|
+
try {
|
|
262
|
+
const badges = await entry.render({
|
|
263
|
+
...context,
|
|
264
|
+
runtime: this.#runtimeFor(entry.pluginKey),
|
|
265
|
+
})
|
|
266
|
+
this.#record(entry.pluginKey, 'threadrow.badges', this.#now() - started)
|
|
267
|
+
for (const [threadId, node] of badges) {
|
|
268
|
+
if (node === null || node === undefined) continue
|
|
269
|
+
const list = byThread.get(threadId) ?? []
|
|
270
|
+
list.push({ key: entry.pluginKey, node })
|
|
271
|
+
byThread.set(threadId, list)
|
|
272
|
+
}
|
|
273
|
+
} catch (error) {
|
|
274
|
+
this.#fail(entry.pluginKey, 'threadrow.badges', error)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return byThread
|
|
278
|
+
}
|
|
279
|
+
|
|
235
280
|
health(): readonly PluginHealth[] {
|
|
236
281
|
return [...this.#stats.entries()]
|
|
237
282
|
.map(([key, stats]) => ({
|
package/src/index.ts
CHANGED
|
@@ -53,18 +53,21 @@ export {
|
|
|
53
53
|
type PluginContribution,
|
|
54
54
|
type PluginDefinition,
|
|
55
55
|
type PluginHooks,
|
|
56
|
+
type PluginIntervalTask,
|
|
56
57
|
type PluginMigration,
|
|
57
58
|
type PluginNavigationAudience,
|
|
58
59
|
type PluginNavigationItem,
|
|
59
60
|
type PluginNotificationKind,
|
|
60
61
|
type PluginPageAccess,
|
|
61
62
|
type PluginPageContext,
|
|
63
|
+
type PluginRegionContribution,
|
|
62
64
|
type PluginRequest,
|
|
63
65
|
type PluginResponse,
|
|
64
66
|
type PluginRoute,
|
|
65
67
|
type PluginRouteAccess,
|
|
66
68
|
type PluginRouteRateLimit,
|
|
67
69
|
type PluginRuntimeContext,
|
|
70
|
+
type PluginScheduledTask,
|
|
68
71
|
type PluginSetting,
|
|
69
72
|
type PluginSettingType,
|
|
70
73
|
type PluginTask,
|
|
@@ -76,8 +79,11 @@ export {
|
|
|
76
79
|
pluginPagePath,
|
|
77
80
|
pluginRoutePath,
|
|
78
81
|
pluginSettingKey,
|
|
82
|
+
pluginStaffPagePath,
|
|
79
83
|
pluginTablePrefix,
|
|
80
84
|
pluginTaskId,
|
|
85
|
+
type ThreadRowBadges,
|
|
86
|
+
type ThreadRowBadgesContribution,
|
|
81
87
|
} from './plugin'
|
|
82
88
|
export {
|
|
83
89
|
createRouteRateLimiter,
|
|
@@ -91,6 +97,8 @@ export {
|
|
|
91
97
|
type PluginRegionContext,
|
|
92
98
|
REGION_NAMES,
|
|
93
99
|
type RegionSpec,
|
|
100
|
+
type ThreadRowBadgeSubject,
|
|
101
|
+
type ThreadRowBadgesContext,
|
|
94
102
|
} from './regions'
|
|
95
103
|
export {
|
|
96
104
|
renderingSignature,
|
package/src/navigation.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
type PluginNavigationAudience,
|
|
4
4
|
pluginNavigationKey,
|
|
5
5
|
pluginPagePath,
|
|
6
|
+
pluginStaffPagePath,
|
|
6
7
|
} from './plugin'
|
|
7
8
|
|
|
8
9
|
export interface PluginNavigationPlacement {
|
|
@@ -21,7 +22,9 @@ export function pluginNavigationPlacements(
|
|
|
21
22
|
return plugins.flatMap((plugin) =>
|
|
22
23
|
(plugin.navigation ?? []).map((item) => ({
|
|
23
24
|
key: pluginNavigationKey(plugin.key, item.key),
|
|
24
|
-
href:
|
|
25
|
+
href: ((plugin.pages ?? []).find((page) => page.path === item.path)?.access === 'staff'
|
|
26
|
+
? pluginStaffPagePath
|
|
27
|
+
: pluginPagePath)(plugin.key, item.path),
|
|
25
28
|
audience: item.audience ?? ('all' as const),
|
|
26
29
|
parentKey: item.under === undefined ? null : pluginNavigationKey(plugin.key, item.under),
|
|
27
30
|
label: item.label,
|
package/src/plugin.ts
CHANGED
|
@@ -2,11 +2,17 @@ export type { HookRuntime, PluginRuntimeContext } from './runtime'
|
|
|
2
2
|
|
|
3
3
|
import type { ReactNode } from 'react'
|
|
4
4
|
|
|
5
|
+
import { CADENCE_REFERENCE, nextRun, parseCron } from '@meith/core'
|
|
5
6
|
import type { Translator } from '@meith/theme-kit'
|
|
6
7
|
|
|
7
8
|
import { type HOOKS, type HookName, isHookName } from './hooks'
|
|
8
9
|
import type { HookContext, HookValue } from './payloads'
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
isPluginRegion,
|
|
12
|
+
type PluginRegion,
|
|
13
|
+
type PluginRegionContext,
|
|
14
|
+
type ThreadRowBadgesContext,
|
|
15
|
+
} from './regions'
|
|
10
16
|
import type { HookRuntime, PluginRuntimeContext } from './runtime'
|
|
11
17
|
|
|
12
18
|
export type FilterHandler<K extends HookName> = (
|
|
@@ -61,12 +67,23 @@ export interface PluginMigration {
|
|
|
61
67
|
readonly statements: readonly string[]
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
|
|
70
|
+
interface PluginTaskBase {
|
|
65
71
|
readonly id: string
|
|
66
|
-
readonly intervalSeconds: number
|
|
67
72
|
readonly run: (context: PluginRuntimeContext) => Promise<void> | void
|
|
68
73
|
}
|
|
69
74
|
|
|
75
|
+
export interface PluginIntervalTask extends PluginTaskBase {
|
|
76
|
+
readonly intervalSeconds: number
|
|
77
|
+
readonly schedule?: undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface PluginScheduledTask extends PluginTaskBase {
|
|
81
|
+
readonly schedule: string
|
|
82
|
+
readonly intervalSeconds?: undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type PluginTask = PluginIntervalTask | PluginScheduledTask
|
|
86
|
+
|
|
70
87
|
export interface PluginAdminPage {
|
|
71
88
|
readonly path: string
|
|
72
89
|
readonly title: string
|
|
@@ -75,12 +92,22 @@ export interface PluginAdminPage {
|
|
|
75
92
|
readonly render: (context: PluginAdminPageContext) => ReactNode | Promise<ReactNode>
|
|
76
93
|
}
|
|
77
94
|
|
|
78
|
-
export interface
|
|
79
|
-
readonly region: PluginRegion
|
|
95
|
+
export interface PluginRegionContribution {
|
|
96
|
+
readonly region: Exclude<PluginRegion, 'threadrow.badges'>
|
|
80
97
|
readonly priority?: number | undefined
|
|
81
98
|
readonly render: (context: PluginRegionContext) => ReactNode | Promise<ReactNode>
|
|
82
99
|
}
|
|
83
100
|
|
|
101
|
+
export type ThreadRowBadges = ReadonlyMap<number, ReactNode>
|
|
102
|
+
|
|
103
|
+
export interface ThreadRowBadgesContribution {
|
|
104
|
+
readonly region: 'threadrow.badges'
|
|
105
|
+
readonly priority?: number | undefined
|
|
106
|
+
readonly render: (context: ThreadRowBadgesContext) => ThreadRowBadges | Promise<ThreadRowBadges>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type PluginContribution = PluginRegionContribution | ThreadRowBadgesContribution
|
|
110
|
+
|
|
84
111
|
export interface PluginViewer {
|
|
85
112
|
readonly userId: number | null
|
|
86
113
|
readonly isGuest: boolean
|
|
@@ -108,8 +135,8 @@ export type PluginResponse =
|
|
|
108
135
|
}
|
|
109
136
|
| { readonly kind: 'redirect'; readonly to: string }
|
|
110
137
|
|
|
111
|
-
export type PluginRouteAccess = 'anonymous' | 'member' | 'admin'
|
|
112
|
-
export type PluginPageAccess = 'anonymous' | 'member'
|
|
138
|
+
export type PluginRouteAccess = 'anonymous' | 'member' | 'staff' | 'admin'
|
|
139
|
+
export type PluginPageAccess = 'anonymous' | 'member' | 'staff'
|
|
113
140
|
|
|
114
141
|
export interface PluginRouteRateLimit {
|
|
115
142
|
readonly limit: number
|
|
@@ -438,7 +465,24 @@ export function definePlugin(plugin: PluginDefinition): PluginDefinition {
|
|
|
438
465
|
`${where}: task id "${task.id}" must be lower-case letters, digits and hyphens.`,
|
|
439
466
|
)
|
|
440
467
|
}
|
|
441
|
-
|
|
468
|
+
|
|
469
|
+
const hasInterval = task.intervalSeconds !== undefined
|
|
470
|
+
const hasSchedule = task.schedule !== undefined
|
|
471
|
+
if (hasInterval === hasSchedule) {
|
|
472
|
+
throw new Error(
|
|
473
|
+
`${where}: task "${task.id}" must set exactly one of intervalSeconds (a fixed cadence) ` +
|
|
474
|
+
'or schedule (a five-field cron expression evaluated in UTC).',
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (task.schedule !== undefined) {
|
|
479
|
+
try {
|
|
480
|
+
nextRun(parseCron(task.schedule), CADENCE_REFERENCE)
|
|
481
|
+
} catch (error) {
|
|
482
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
483
|
+
throw new Error(`${where}: task "${task.id}" ${detail}.`)
|
|
484
|
+
}
|
|
485
|
+
} else if (!Number.isInteger(task.intervalSeconds) || task.intervalSeconds < 60) {
|
|
442
486
|
throw new Error(
|
|
443
487
|
`${where}: task "${task.id}" has an interval of ${task.intervalSeconds}s. The tick ` +
|
|
444
488
|
'is minute-granular at best on a serverless platform; anything under 60s is a ' +
|
|
@@ -487,9 +531,14 @@ export function definePlugin(plugin: PluginDefinition): PluginDefinition {
|
|
|
487
531
|
if (route.method !== 'GET' && route.method !== 'POST') {
|
|
488
532
|
throw new Error(`${where}: route "${route.path}" method must be GET or POST.`)
|
|
489
533
|
}
|
|
490
|
-
if (
|
|
534
|
+
if (
|
|
535
|
+
route.access !== 'anonymous' &&
|
|
536
|
+
route.access !== 'member' &&
|
|
537
|
+
route.access !== 'staff' &&
|
|
538
|
+
route.access !== 'admin'
|
|
539
|
+
) {
|
|
491
540
|
throw new Error(
|
|
492
|
-
`${where}: route "${route.path}" access must be "anonymous", "member" or "admin".`,
|
|
541
|
+
`${where}: route "${route.path}" access must be "anonymous", "member", "staff" or "admin".`,
|
|
493
542
|
)
|
|
494
543
|
}
|
|
495
544
|
if (typeof route.handler !== 'function') {
|
|
@@ -539,8 +588,10 @@ export function definePlugin(plugin: PluginDefinition): PluginDefinition {
|
|
|
539
588
|
if (page.title.trim() === '') {
|
|
540
589
|
throw new Error(`${where}: the page at "${page.path}" needs a title.`)
|
|
541
590
|
}
|
|
542
|
-
if (page.access !== 'anonymous' && page.access !== 'member') {
|
|
543
|
-
throw new Error(
|
|
591
|
+
if (page.access !== 'anonymous' && page.access !== 'member' && page.access !== 'staff') {
|
|
592
|
+
throw new Error(
|
|
593
|
+
`${where}: page "${page.path}" access must be "anonymous", "member" or "staff".`,
|
|
594
|
+
)
|
|
544
595
|
}
|
|
545
596
|
if (typeof page.render !== 'function') {
|
|
546
597
|
throw new Error(`${where}: page "${page.path}" needs a render function.`)
|
|
@@ -674,6 +725,10 @@ export function pluginPagePath(pluginKey: string, path: string): string {
|
|
|
674
725
|
return `/plugins/${pluginKey}${path === '' ? '' : `/${path}`}`
|
|
675
726
|
}
|
|
676
727
|
|
|
728
|
+
export function pluginStaffPagePath(pluginKey: string, path: string): string {
|
|
729
|
+
return `/modcp/plugins/${pluginKey}${path === '' ? '' : `/${path}`}`
|
|
730
|
+
}
|
|
731
|
+
|
|
677
732
|
export function pluginNavigationKey(pluginKey: string, itemKey: string): string {
|
|
678
733
|
return `plugin.${pluginKey}.${itemKey}`
|
|
679
734
|
}
|
package/src/regions.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Translator } from '@meith/theme-kit'
|
|
2
|
+
|
|
1
3
|
import type { HookRuntime } from './runtime'
|
|
2
4
|
|
|
3
5
|
export interface RegionSpec {
|
|
@@ -8,35 +10,50 @@ export interface RegionSpec {
|
|
|
8
10
|
export const PLUGIN_REGIONS = {
|
|
9
11
|
'header.notice': {
|
|
10
12
|
purpose: 'Directly below the board header, above the page body. Board-wide notices.',
|
|
11
|
-
context: 'The viewer.',
|
|
13
|
+
context: 'The viewer, with the reader’s locale and a translator.',
|
|
12
14
|
},
|
|
13
15
|
'index.footer': {
|
|
14
16
|
purpose: 'The bottom of the board index, below the statistics block.',
|
|
15
|
-
context: 'The viewer.',
|
|
17
|
+
context: 'The viewer, with the reader’s locale and a translator.',
|
|
16
18
|
},
|
|
17
19
|
'postbit.badges': {
|
|
18
20
|
purpose:
|
|
19
21
|
'Beside a post author’s name. Runs once per post on every thread page — the ' +
|
|
20
22
|
'most expensive region on the board, and the one to keep trivial.',
|
|
21
|
-
context:
|
|
23
|
+
context:
|
|
24
|
+
'The viewer, the post id and the author id, with the reader’s locale and a translator.',
|
|
22
25
|
},
|
|
23
26
|
'postbit.footer': {
|
|
24
27
|
purpose: 'Below a post body, above its actions.',
|
|
25
|
-
context:
|
|
28
|
+
context:
|
|
29
|
+
'The viewer, the post id and the author id, with the reader’s locale and a translator.',
|
|
30
|
+
},
|
|
31
|
+
'threadrow.badges': {
|
|
32
|
+
purpose:
|
|
33
|
+
'Beside a thread’s title in a listing, to mark threads across a forum page. A ' +
|
|
34
|
+
'batch region: unlike every other region it runs once per page, not once per ' +
|
|
35
|
+
'row — a listing of twenty threads is one call, returning a badge per thread ' +
|
|
36
|
+
'id — because a forum page is on a tight budget and a per-row region there is ' +
|
|
37
|
+
'twenty calls before the page has drawn a thing.',
|
|
38
|
+
context:
|
|
39
|
+
'The viewer and the page’s visible threads, each as a thread id and its author ' +
|
|
40
|
+
'id, with the reader’s locale and a translator.',
|
|
26
41
|
},
|
|
27
42
|
'thread.header': {
|
|
28
43
|
purpose:
|
|
29
44
|
'Above the first post of a thread, below its title. Runs once per thread page, ' +
|
|
30
45
|
'so unlike postbit.* it can afford to read from the plugin’s own tables.',
|
|
31
|
-
context:
|
|
46
|
+
context:
|
|
47
|
+
'The viewer, the thread id and the thread author’s id, with the reader’s locale and ' +
|
|
48
|
+
'a translator.',
|
|
32
49
|
},
|
|
33
50
|
'profile.panel': {
|
|
34
51
|
purpose: 'A panel on a member’s profile, below the standard fields.',
|
|
35
|
-
context: 'The viewer and the profile’s member id.',
|
|
52
|
+
context: 'The viewer and the profile’s member id, with the reader’s locale and a translator.',
|
|
36
53
|
},
|
|
37
54
|
'admin.dashboard': {
|
|
38
55
|
purpose: 'A card on the admin dashboard. Only rendered for administrators.',
|
|
39
|
-
context: 'The viewer.',
|
|
56
|
+
context: 'The viewer, with the reader’s locale and a translator.',
|
|
40
57
|
},
|
|
41
58
|
} as const satisfies Readonly<Record<string, RegionSpec>>
|
|
42
59
|
|
|
@@ -53,5 +70,20 @@ export interface PluginRegionContext {
|
|
|
53
70
|
readonly viewer: { readonly userId: number | null; readonly isGuest: boolean }
|
|
54
71
|
readonly subjectId: number | null
|
|
55
72
|
readonly authorId: number | null
|
|
73
|
+
readonly locale: string
|
|
74
|
+
readonly t: Translator
|
|
75
|
+
readonly runtime: HookRuntime
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ThreadRowBadgeSubject {
|
|
79
|
+
readonly threadId: number
|
|
80
|
+
readonly authorId: number | null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ThreadRowBadgesContext {
|
|
84
|
+
readonly viewer: { readonly userId: number | null; readonly isGuest: boolean }
|
|
85
|
+
readonly threads: readonly ThreadRowBadgeSubject[]
|
|
86
|
+
readonly locale: string
|
|
87
|
+
readonly t: Translator
|
|
56
88
|
readonly runtime: HookRuntime
|
|
57
89
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -25,13 +25,15 @@ export interface PluginGrants {
|
|
|
25
25
|
}): Promise<void>
|
|
26
26
|
|
|
27
27
|
list(userId: number): Promise<readonly PluginGrantRow[]>
|
|
28
|
+
|
|
29
|
+
holds(userId: number, groupKey: string): Promise<boolean>
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export function unavailablePluginGrants(reason: string): PluginGrants {
|
|
31
33
|
const refuse = async (): Promise<never> => {
|
|
32
34
|
throw new Error(`Plugin grants are unavailable: ${reason}`)
|
|
33
35
|
}
|
|
34
|
-
return { grant: refuse, extend: refuse, revoke: refuse, list: refuse }
|
|
36
|
+
return { grant: refuse, extend: refuse, revoke: refuse, list: refuse, holds: refuse }
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export interface PluginData {
|