@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/runtime.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
|
|
2
|
+
export interface PluginGrantRow {
|
|
3
|
+
readonly groupKey: string
|
|
4
|
+
readonly expiresAt: Date
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface PluginGrants {
|
|
8
|
+
grant(input: {
|
|
9
|
+
readonly userId: number
|
|
10
|
+
readonly groupKey: string
|
|
11
|
+
readonly until: Date
|
|
12
|
+
readonly reason: string
|
|
13
|
+
}): Promise<void>
|
|
14
|
+
|
|
15
|
+
extend(input: {
|
|
16
|
+
readonly userId: number
|
|
17
|
+
readonly groupKey: string
|
|
18
|
+
readonly until: Date
|
|
19
|
+
}): Promise<void>
|
|
20
|
+
|
|
21
|
+
revoke(input: {
|
|
22
|
+
readonly userId: number
|
|
23
|
+
readonly groupKey: string
|
|
24
|
+
readonly reason: string
|
|
25
|
+
}): Promise<void>
|
|
26
|
+
|
|
27
|
+
list(userId: number): Promise<readonly PluginGrantRow[]>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function unavailablePluginGrants(reason: string): PluginGrants {
|
|
31
|
+
const refuse = async (): Promise<never> => {
|
|
32
|
+
throw new Error(`Plugin grants are unavailable: ${reason}`)
|
|
33
|
+
}
|
|
34
|
+
return { grant: refuse, extend: refuse, revoke: refuse, list: refuse }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PluginData {
|
|
38
|
+
query<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
39
|
+
text: string,
|
|
40
|
+
params?: readonly unknown[],
|
|
41
|
+
): Promise<readonly T[]>
|
|
42
|
+
|
|
43
|
+
one<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
44
|
+
text: string,
|
|
45
|
+
params?: readonly unknown[],
|
|
46
|
+
): Promise<T | null>
|
|
47
|
+
|
|
48
|
+
tx<T>(work: (data: PluginData) => Promise<T>): Promise<T>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function unavailablePluginData(reason: string): PluginData {
|
|
52
|
+
const refuse = async (): Promise<never> => {
|
|
53
|
+
throw new Error(`Plugin data access is unavailable: ${reason}`)
|
|
54
|
+
}
|
|
55
|
+
return { query: refuse, one: refuse, tx: refuse }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PluginUserRef {
|
|
59
|
+
readonly userId: number
|
|
60
|
+
readonly username: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface PluginUsers {
|
|
64
|
+
byUsername(username: string): Promise<PluginUserRef | null>
|
|
65
|
+
byId(userId: number): Promise<PluginUserRef | null>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function unavailablePluginUsers(reason: string): PluginUsers {
|
|
69
|
+
const refuse = async (): Promise<never> => {
|
|
70
|
+
throw new Error(`Plugin user lookup is unavailable: ${reason}`)
|
|
71
|
+
}
|
|
72
|
+
return { byUsername: refuse, byId: refuse }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface PluginNotify {
|
|
76
|
+
send(input: {
|
|
77
|
+
readonly userId: number
|
|
78
|
+
readonly kind: string
|
|
79
|
+
readonly subject: string
|
|
80
|
+
readonly body?: string | undefined
|
|
81
|
+
readonly href?: string | undefined
|
|
82
|
+
readonly dedupeKey?: string | undefined
|
|
83
|
+
}): Promise<void>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function unavailablePluginNotify(reason: string): PluginNotify {
|
|
87
|
+
const refuse = async (): Promise<never> => {
|
|
88
|
+
throw new Error(`Plugin notifications are unavailable: ${reason}`)
|
|
89
|
+
}
|
|
90
|
+
return { send: refuse }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const MAX_NOTIFY_SUBJECT = 200
|
|
94
|
+
const MAX_NOTIFY_BODY = 2_000
|
|
95
|
+
|
|
96
|
+
export interface PluginNotifyKindInput {
|
|
97
|
+
readonly key: string
|
|
98
|
+
readonly title: string
|
|
99
|
+
readonly description: string
|
|
100
|
+
readonly emailByDefault?: boolean | undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface PluginNotificationKindSpec {
|
|
104
|
+
readonly id: string
|
|
105
|
+
readonly title: string
|
|
106
|
+
readonly description: string
|
|
107
|
+
readonly audience: 'member'
|
|
108
|
+
readonly emailByDefault: boolean
|
|
109
|
+
readonly emailConfigurable: true
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface PluginNotifyBackend {
|
|
113
|
+
raise(input: {
|
|
114
|
+
readonly userId: number
|
|
115
|
+
readonly kind: string
|
|
116
|
+
readonly data: Readonly<Record<string, string>>
|
|
117
|
+
readonly href?: string | null
|
|
118
|
+
readonly dedupeKey?: string | null
|
|
119
|
+
}): Promise<unknown>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function pluginNotificationKindSpecs(
|
|
123
|
+
pluginKey: string,
|
|
124
|
+
kinds: readonly PluginNotifyKindInput[],
|
|
125
|
+
): readonly PluginNotificationKindSpec[] {
|
|
126
|
+
return kinds.map((kind) => ({
|
|
127
|
+
id: `plugin.${pluginKey}.${kind.key}`,
|
|
128
|
+
title: kind.title,
|
|
129
|
+
description: kind.description,
|
|
130
|
+
audience: 'member',
|
|
131
|
+
emailByDefault: kind.emailByDefault ?? true,
|
|
132
|
+
emailConfigurable: true,
|
|
133
|
+
}))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function pluginNotify(
|
|
137
|
+
pluginKey: string,
|
|
138
|
+
kinds: readonly PluginNotifyKindInput[],
|
|
139
|
+
backend: PluginNotifyBackend,
|
|
140
|
+
): PluginNotify {
|
|
141
|
+
const declared = new Set(kinds.map((kind) => kind.key))
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
async send(input) {
|
|
145
|
+
if (!declared.has(input.kind)) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`plugin "${pluginKey}": notification kind "${input.kind}" is not declared. ` +
|
|
148
|
+
'A kind must be in the plugin definition — that is what puts it on the ' +
|
|
149
|
+
'member’s preferences screen.',
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
if (!Number.isSafeInteger(input.userId) || input.userId <= 0) {
|
|
153
|
+
throw new Error(`plugin "${pluginKey}": a notification needs a real user id.`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const subject = input.subject.trim()
|
|
157
|
+
if (subject === '' || subject.length > MAX_NOTIFY_SUBJECT) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`plugin "${pluginKey}": a notification subject is 1 to ${MAX_NOTIFY_SUBJECT} characters.`,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const body = (input.body ?? '').trim()
|
|
164
|
+
if (body.length > MAX_NOTIFY_BODY) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`plugin "${pluginKey}": a notification body caps at ${MAX_NOTIFY_BODY} characters — ` +
|
|
167
|
+
'link to a page for anything longer.',
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (input.href !== undefined && (!input.href.startsWith('/') || input.href.startsWith('//'))) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`plugin "${pluginKey}": a notification links within the board — the href must ` +
|
|
174
|
+
'start with a single "/".',
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await backend.raise({
|
|
179
|
+
userId: input.userId,
|
|
180
|
+
kind: `plugin.${pluginKey}.${input.kind}`,
|
|
181
|
+
data: body === '' ? { subject } : { subject, body },
|
|
182
|
+
href: input.href ?? null,
|
|
183
|
+
dedupeKey: input.dedupeKey ?? null,
|
|
184
|
+
})
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { PluginDefinition, PluginSetting, PluginSettingType } from './plugin'
|
|
2
|
+
|
|
3
|
+
export type PluginSettingValue = string | number | boolean
|
|
4
|
+
|
|
5
|
+
export type PluginEnvReader = (name: string) => string | undefined
|
|
6
|
+
|
|
7
|
+
export type PluginSettingSource = 'environment' | 'board' | 'default'
|
|
8
|
+
|
|
9
|
+
export interface ResolvedPluginSetting {
|
|
10
|
+
readonly setting: PluginSetting
|
|
11
|
+
readonly value: PluginSettingValue
|
|
12
|
+
readonly source: PluginSettingSource
|
|
13
|
+
readonly problem: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function pluginSettingType(setting: PluginSetting): PluginSettingType {
|
|
17
|
+
if (setting.type !== undefined) return setting.type
|
|
18
|
+
if (typeof setting.default === 'boolean') return 'boolean'
|
|
19
|
+
if (typeof setting.default === 'number') return 'number'
|
|
20
|
+
return 'string'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ENABLED_SUFFIX = '_enabled'
|
|
24
|
+
|
|
25
|
+
export function pluginEnabledKey(pluginKey: string): string {
|
|
26
|
+
return `plugin.${pluginKey}.${ENABLED_SUFFIX}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function serialisePluginSetting(value: PluginSettingValue): string {
|
|
30
|
+
if (typeof value === 'boolean') return value ? '1' : '0'
|
|
31
|
+
return String(value)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parsePluginSetting(
|
|
35
|
+
setting: PluginSetting,
|
|
36
|
+
raw: string,
|
|
37
|
+
): PluginSettingValue | null {
|
|
38
|
+
if (typeof setting.default === 'boolean') {
|
|
39
|
+
if (raw === '1' || raw === 'true') return true
|
|
40
|
+
if (raw === '0' || raw === 'false') return false
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof setting.default === 'number') {
|
|
45
|
+
if (raw.trim() === '') return null
|
|
46
|
+
const parsed = Number(raw)
|
|
47
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return raw
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveOne(
|
|
54
|
+
plugin: PluginDefinition,
|
|
55
|
+
setting: PluginSetting,
|
|
56
|
+
overrides: ReadonlyMap<string, string>,
|
|
57
|
+
env: PluginEnvReader | undefined,
|
|
58
|
+
): ResolvedPluginSetting {
|
|
59
|
+
const type = pluginSettingType(setting)
|
|
60
|
+
|
|
61
|
+
const fromEnv = setting.env === undefined ? undefined : env?.(setting.env)
|
|
62
|
+
const stored = overrides.get(`plugin.${plugin.key}.${setting.key}`)
|
|
63
|
+
|
|
64
|
+
let value: PluginSettingValue | null = null
|
|
65
|
+
let source: PluginSettingSource = 'default'
|
|
66
|
+
|
|
67
|
+
if (fromEnv !== undefined && fromEnv.trim() !== '') {
|
|
68
|
+
value = parsePluginSetting(setting, fromEnv)
|
|
69
|
+
source = 'environment'
|
|
70
|
+
}
|
|
71
|
+
if (value === null && stored !== undefined) {
|
|
72
|
+
value = parsePluginSetting(setting, stored)
|
|
73
|
+
if (value !== null) source = 'board'
|
|
74
|
+
}
|
|
75
|
+
if (value === null) {
|
|
76
|
+
value = setting.default
|
|
77
|
+
source = 'default'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (type === 'select' && !(setting.options ?? []).some((option) => option.value === value)) {
|
|
81
|
+
value = setting.default
|
|
82
|
+
source = 'default'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const problem =
|
|
86
|
+
setting.required === true && (value === '' || value === null)
|
|
87
|
+
? setting.env === undefined
|
|
88
|
+
? `“${setting.label}” is required and not set.`
|
|
89
|
+
: `“${setting.label}” is required — set it here or with ${setting.env}.`
|
|
90
|
+
: null
|
|
91
|
+
|
|
92
|
+
return { setting, value, source, problem }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function resolvePluginSettings(
|
|
96
|
+
plugin: PluginDefinition,
|
|
97
|
+
overrides: ReadonlyMap<string, string>,
|
|
98
|
+
env?: PluginEnvReader,
|
|
99
|
+
): Readonly<Record<string, PluginSettingValue>> {
|
|
100
|
+
const resolved: Record<string, PluginSettingValue> = {}
|
|
101
|
+
|
|
102
|
+
for (const setting of plugin.settings ?? []) {
|
|
103
|
+
resolved[setting.key] = resolveOne(plugin, setting, overrides, env).value
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return resolved
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function resolvePluginSettingDetails(
|
|
110
|
+
plugin: PluginDefinition,
|
|
111
|
+
overrides: ReadonlyMap<string, string>,
|
|
112
|
+
env?: PluginEnvReader,
|
|
113
|
+
): readonly ResolvedPluginSetting[] {
|
|
114
|
+
return (plugin.settings ?? []).map((setting) => resolveOne(plugin, setting, overrides, env))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function operatorDisabledPlugins(
|
|
118
|
+
overrides: ReadonlyMap<string, string>,
|
|
119
|
+
): readonly string[] {
|
|
120
|
+
const disabled: string[] = []
|
|
121
|
+
|
|
122
|
+
for (const [key, value] of overrides) {
|
|
123
|
+
if (!key.startsWith('plugin.') || !key.endsWith(`.${ENABLED_SUFFIX}`)) continue
|
|
124
|
+
if (value !== '0') continue
|
|
125
|
+
|
|
126
|
+
const pluginKey = key.slice('plugin.'.length, -`.${ENABLED_SUFFIX}`.length)
|
|
127
|
+
if (pluginKey !== '' && !pluginKey.includes('.')) disabled.push(pluginKey)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return disabled.sort()
|
|
131
|
+
}
|