@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/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
+ }
@@ -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
+ }