@skillcdn/console 0.4.4 → 0.4.5
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/README.md +3 -3
- package/dist/api.d.ts +3 -3
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +3 -3
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +162 -15
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +16 -3
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +68 -19
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +26 -5
- package/dist/cli/help.js.map +1 -1
- package/dist/client.d.ts +28 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +12 -2
- package/dist/client.js.map +1 -1
- package/dist/compat.js +1 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +46 -15
- package/dist/components/board.js.map +1 -1
- package/dist/components/decision-form.d.ts +0 -3
- package/dist/components/decision-form.d.ts.map +1 -1
- package/dist/components/decision-form.js +3 -15
- package/dist/components/decision-form.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +30 -10
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/my-decisions.d.ts +20 -3
- package/dist/components/my-decisions.d.ts.map +1 -1
- package/dist/components/my-decisions.js +14 -1
- package/dist/components/my-decisions.js.map +1 -1
- package/dist/components/people.d.ts +43 -1
- package/dist/components/people.d.ts.map +1 -1
- package/dist/components/people.js +117 -9
- package/dist/components/people.js.map +1 -1
- package/dist/components/picker.d.ts +8 -0
- package/dist/components/picker.d.ts.map +1 -1
- package/dist/components/picker.js +2 -2
- package/dist/components/picker.js.map +1 -1
- package/dist/components/plugin-frame.d.ts +0 -1
- package/dist/components/plugin-frame.d.ts.map +1 -1
- package/dist/components/plugin-frame.js +22 -1
- package/dist/components/plugin-frame.js.map +1 -1
- package/dist/components/plugin-settings.d.ts +5 -1
- package/dist/components/plugin-settings.d.ts.map +1 -1
- package/dist/components/plugin-settings.js +143 -30
- package/dist/components/plugin-settings.js.map +1 -1
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +3 -1
- package/dist/components/projects.js.map +1 -1
- package/dist/components/searches.d.ts +11 -0
- package/dist/components/searches.d.ts.map +1 -0
- package/dist/components/searches.js +30 -0
- package/dist/components/searches.js.map +1 -0
- package/dist/components/task-form.d.ts.map +1 -1
- package/dist/components/task-form.js +6 -5
- package/dist/components/task-form.js.map +1 -1
- package/dist/components/ui.d.ts +16 -0
- package/dist/components/ui.d.ts.map +1 -1
- package/dist/components/ui.js +10 -0
- package/dist/components/ui.js.map +1 -1
- package/dist/console.css +362 -67
- package/dist/console.d.ts +2 -1
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +90 -14
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +14 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +36 -1
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +89 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +87 -2
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +87 -3
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +6 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +6 -0
- package/dist/limits.js.map +1 -1
- package/dist/plugin/args.d.ts +6 -1
- package/dist/plugin/args.d.ts.map +1 -1
- package/dist/plugin/args.js +16 -11
- package/dist/plugin/args.js.map +1 -1
- package/dist/plugin/bridge.d.ts +20 -1
- package/dist/plugin/bridge.d.ts.map +1 -1
- package/dist/plugin/bridge.js +28 -0
- package/dist/plugin/bridge.js.map +1 -1
- package/dist/plugin/capabilities.d.ts +1 -1
- package/dist/plugin/capabilities.d.ts.map +1 -1
- package/dist/plugin/capabilities.js +10 -0
- package/dist/plugin/capabilities.js.map +1 -1
- package/dist/plugin/context.d.ts +158 -13
- package/dist/plugin/context.d.ts.map +1 -1
- package/dist/plugin/define.d.ts +13 -1
- package/dist/plugin/define.d.ts.map +1 -1
- package/dist/plugin/define.js +17 -1
- package/dist/plugin/define.js.map +1 -1
- package/dist/plugin/limits.d.ts +4 -0
- package/dist/plugin/limits.d.ts.map +1 -1
- package/dist/plugin/limits.js +4 -0
- package/dist/plugin/limits.js.map +1 -1
- package/dist/plugin/manifest.d.ts +15 -0
- package/dist/plugin/manifest.d.ts.map +1 -1
- package/dist/plugin/manifest.js +61 -4
- package/dist/plugin/manifest.js.map +1 -1
- package/dist/plugin/rest-schemas.d.ts +44 -0
- package/dist/plugin/rest-schemas.d.ts.map +1 -1
- package/dist/plugin/rest-schemas.js +55 -3
- package/dist/plugin/rest-schemas.js.map +1 -1
- package/dist/plugin/testing.d.ts +15 -1
- package/dist/plugin/testing.d.ts.map +1 -1
- package/dist/plugin/testing.js +40 -4
- package/dist/plugin/testing.js.map +1 -1
- package/dist/plugin/web.d.ts +6 -0
- package/dist/plugin/web.d.ts.map +1 -1
- package/dist/plugin/web.js +10 -1
- package/dist/plugin/web.js.map +1 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +1 -1
- package/dist/plugin.js.map +1 -1
- package/dist/router.d.ts +19 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +18 -0
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +11 -2
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +14 -1
- package/dist/routes.js.map +1 -1
- package/dist/schemas.d.ts +367 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +62 -2
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +1 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +6 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +7 -4
- package/docs/specs/plugins.md +47 -39
- package/docs/specs/rest.md +22 -5
- package/package.json +1 -1
- package/skills/building-a-plugin/SKILL.md +8 -6
- package/skills/working-the-board/SKILL.md +1 -1
- package/src/api.ts +17 -0
- package/src/cli/cli.ts +200 -12
- package/src/cli/format.ts +82 -19
- package/src/cli/help.ts +30 -5
- package/src/client.ts +70 -0
- package/src/compat.ts +1 -1
- package/src/components/board.tsx +83 -42
- package/src/components/decision-form.tsx +13 -32
- package/src/components/event-feed.tsx +36 -9
- package/src/components/my-decisions.tsx +50 -5
- package/src/components/people.tsx +394 -37
- package/src/components/picker.tsx +11 -2
- package/src/components/plugin-frame.tsx +23 -0
- package/src/components/plugin-settings.tsx +346 -137
- package/src/components/projects.tsx +14 -14
- package/src/components/searches.ts +47 -0
- package/src/components/task-form.tsx +23 -28
- package/src/components/ui.tsx +36 -0
- package/src/console.tsx +229 -40
- package/src/data.ts +53 -1
- package/src/i18n/en.ts +98 -2
- package/src/i18n/ko.ts +95 -3
- package/src/index.ts +11 -0
- package/src/limits.ts +6 -0
- package/src/plugin/args.ts +16 -11
- package/src/plugin/bridge.ts +45 -0
- package/src/plugin/capabilities.ts +11 -0
- package/src/plugin/context.ts +170 -10
- package/src/plugin/define.ts +34 -0
- package/src/plugin/limits.ts +4 -0
- package/src/plugin/manifest.ts +74 -3
- package/src/plugin/rest-schemas.ts +62 -5
- package/src/plugin/testing.ts +61 -5
- package/src/plugin/web.ts +17 -0
- package/src/plugin.ts +1 -0
- package/src/router.ts +25 -0
- package/src/routes.ts +17 -2
- package/src/schemas.ts +81 -2
- package/src/styles/console.css +362 -67
- package/src/vocabulary.ts +6 -0
package/src/plugin/bridge.ts
CHANGED
|
@@ -160,6 +160,17 @@ export interface BridgeDownload {
|
|
|
160
160
|
readonly id: string;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/**
|
|
164
|
+
* A link a person pressed in the frame, for the console's page to open in a new tab (ADR-0067):
|
|
165
|
+
* the frame's sandbox opens no window itself, and the page opens one only for a web or a mail
|
|
166
|
+
* address, and only right after a person pressed.
|
|
167
|
+
*/
|
|
168
|
+
export interface BridgeOpen {
|
|
169
|
+
readonly v: 1;
|
|
170
|
+
readonly t: "open";
|
|
171
|
+
readonly url: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
163
174
|
export interface BridgeSubscribe {
|
|
164
175
|
readonly v: 1;
|
|
165
176
|
readonly t: "subscribe";
|
|
@@ -183,6 +194,7 @@ export type BridgeToPage =
|
|
|
183
194
|
| BridgeResize
|
|
184
195
|
| BridgeTitle
|
|
185
196
|
| BridgeDownload
|
|
197
|
+
| BridgeOpen
|
|
186
198
|
| BridgeSubscribe;
|
|
187
199
|
|
|
188
200
|
/** A path of the console's, as `navigate` and `path` carry one: absolute, no scheme, no host, one line. */
|
|
@@ -212,6 +224,37 @@ export function isBridgePath(value: unknown): value is string {
|
|
|
212
224
|
);
|
|
213
225
|
}
|
|
214
226
|
|
|
227
|
+
/** The schemes a link pressed in a frame may open in a new tab: the web, and mail. Nothing that runs. */
|
|
228
|
+
const OPENABLE_SCHEMES: ReadonlySet<string> = new Set(["https:", "http:", "mailto:"]);
|
|
229
|
+
export const MAX_BRIDGE_URL_LENGTH = 2048;
|
|
230
|
+
|
|
231
|
+
/** True for an address the page may open in a new tab for a frame: an absolute web or mail address, one line. */
|
|
232
|
+
export function isBridgeUrl(value: unknown): value is string {
|
|
233
|
+
if (
|
|
234
|
+
typeof value !== "string" ||
|
|
235
|
+
value.length === 0 ||
|
|
236
|
+
value.length > MAX_BRIDGE_URL_LENGTH ||
|
|
237
|
+
/\s/.test(value) ||
|
|
238
|
+
[...value].some((character) => {
|
|
239
|
+
const code = character.codePointAt(0) ?? 0;
|
|
240
|
+
return code < 0x20 || code === 0x7f;
|
|
241
|
+
}) ||
|
|
242
|
+
!URL.canParse(value)
|
|
243
|
+
) {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
return OPENABLE_SCHEMES.has(new URL(value).protocol);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* True for an address the page opens for a frame (ADR-0067): a web or a mail address that is not
|
|
251
|
+
* the console's own, given as `origin`, since a page of the console's is `navigate`'s, in the
|
|
252
|
+
* person's tab.
|
|
253
|
+
*/
|
|
254
|
+
export function isOpenableFrom(value: unknown, origin: string): value is string {
|
|
255
|
+
return isBridgeUrl(value) && new URL(value).origin !== origin;
|
|
256
|
+
}
|
|
257
|
+
|
|
215
258
|
function isTheme(value: unknown): value is Record<string, string> {
|
|
216
259
|
if (!isRecord(value)) {
|
|
217
260
|
return false;
|
|
@@ -331,6 +374,8 @@ export function isBridgeToPage(value: unknown): value is BridgeToPage {
|
|
|
331
374
|
);
|
|
332
375
|
case "download":
|
|
333
376
|
return isLine(value.id, MAX_ID_LENGTH);
|
|
377
|
+
case "open":
|
|
378
|
+
return isBridgeUrl(value.url);
|
|
334
379
|
case "subscribe":
|
|
335
380
|
return (
|
|
336
381
|
Array.isArray(value.kinds) &&
|
|
@@ -8,6 +8,8 @@ export const PLUGIN_CAPABILITIES = [
|
|
|
8
8
|
"files",
|
|
9
9
|
"people:read",
|
|
10
10
|
"people:read:all",
|
|
11
|
+
"people:read:email",
|
|
12
|
+
"people:act",
|
|
11
13
|
"projects:read",
|
|
12
14
|
"projects:read:all",
|
|
13
15
|
"board:read",
|
|
@@ -60,6 +62,15 @@ export const CAPABILITY_WORDS: Readonly<Record<PluginCapability, CapabilityWords
|
|
|
60
62
|
opens: "everyone in the workspace, for its jobs and hooks too",
|
|
61
63
|
reach: "the directory",
|
|
62
64
|
},
|
|
65
|
+
"people:read:email": {
|
|
66
|
+
opens: "the work email of the people it may read, to match them to accounts elsewhere",
|
|
67
|
+
reach: "the addresses of the people its role holders may see",
|
|
68
|
+
},
|
|
69
|
+
"people:act": {
|
|
70
|
+
opens:
|
|
71
|
+
"acting for a person who did something elsewhere: a command run and a decision answered as them, within what they may do, recorded as theirs through the plugin",
|
|
72
|
+
reach: "what its role holders may do, done in their name from elsewhere",
|
|
73
|
+
},
|
|
63
74
|
"projects:read": {
|
|
64
75
|
opens: "the projects and members the caller may see",
|
|
65
76
|
reach: "what the person sees",
|
package/src/plugin/context.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
RestTask,
|
|
16
16
|
RestTaskInput,
|
|
17
17
|
RestTaskPatch,
|
|
18
|
+
RestTeam,
|
|
18
19
|
} from "../schemas.js";
|
|
19
20
|
import type { WeekStart } from "../vocabulary.js";
|
|
20
21
|
import type { PluginFileHandle } from "./args.js";
|
|
@@ -41,6 +42,16 @@ export type RecordSchemaResult<T> =
|
|
|
41
42
|
/** The data a record of a collection holds, from its schema. */
|
|
42
43
|
export type RecordData<S> = S extends RecordSchema<infer T> ? T : never;
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* How long a collection keeps a record: `days` after the moment `by` names, `createdAt` or
|
|
47
|
+
* `updatedAt` of the record, or an indexed field of its data that holds a day or an instant as
|
|
48
|
+
* ISO 8601 text; then the console removes the record, with its versions, links and files.
|
|
49
|
+
*/
|
|
50
|
+
export interface CollectionRetention {
|
|
51
|
+
readonly days: number;
|
|
52
|
+
readonly by: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
/** A collection as `defineCollection` yields it: the name, the schema, and what the console indexes and who reads. */
|
|
45
56
|
export interface CollectionDefinition<S extends RecordSchema = RecordSchema> {
|
|
46
57
|
readonly name: string;
|
|
@@ -52,6 +63,8 @@ export interface CollectionDefinition<S extends RecordSchema = RecordSchema> {
|
|
|
52
63
|
readonly read: readonly string[];
|
|
53
64
|
readonly readOwn: readonly string[];
|
|
54
65
|
readonly history: boolean;
|
|
66
|
+
/** How long a record is kept, or `null` for as long as the plugin is. */
|
|
67
|
+
readonly retention: CollectionRetention | null;
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
export interface PluginRecord<T = Record<string, unknown>> {
|
|
@@ -209,6 +222,14 @@ export interface PluginInfo {
|
|
|
209
222
|
readonly board: { readonly id: string; readonly key: string } | null;
|
|
210
223
|
/** The projects the plugin is on: all, or their keys. */
|
|
211
224
|
readonly projects: "all" | readonly string[];
|
|
225
|
+
/** The roles other plugins granted to this one (ADR-0072), by their key, so that a plugin says what it lacks. */
|
|
226
|
+
readonly grants: readonly PluginGrantHeld[];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A role of another plugin, held by this one. */
|
|
230
|
+
export interface PluginGrantHeld {
|
|
231
|
+
readonly plugin: string;
|
|
232
|
+
readonly role: string;
|
|
212
233
|
}
|
|
213
234
|
|
|
214
235
|
export interface CallerPerson {
|
|
@@ -245,14 +266,43 @@ export interface ConsoleCaller {
|
|
|
245
266
|
readonly project: CallerProject | null;
|
|
246
267
|
}
|
|
247
268
|
|
|
248
|
-
|
|
269
|
+
/**
|
|
270
|
+
* Another plugin, calling with the roles granted to it (ADR-0072): from its own job, hook or
|
|
271
|
+
* webhook, as itself; or for a person who did something elsewhere, with `for`, and then with the
|
|
272
|
+
* roles the plugin and the person both hold, in the person's language. A plugin that knows only
|
|
273
|
+
* `person` and `console` refuses the kind, which fails safe.
|
|
274
|
+
*/
|
|
275
|
+
export interface PluginAsCaller {
|
|
276
|
+
readonly kind: "plugin";
|
|
277
|
+
/** The calling plugin's key. */
|
|
278
|
+
readonly plugin: string;
|
|
279
|
+
readonly roles: readonly string[];
|
|
280
|
+
/** The person the plugin acts for, or `null` when it acts as itself; never an administrator by that. */
|
|
281
|
+
readonly for: CallerPerson | null;
|
|
282
|
+
/** The project the call is on, if any: the one the person's own call was on. */
|
|
283
|
+
readonly project: CallerProject | null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export type PluginCaller = PersonCaller | ConsoleCaller | PluginAsCaller;
|
|
249
287
|
|
|
250
288
|
export interface PluginWorkspace {
|
|
251
289
|
readonly name: string;
|
|
252
290
|
readonly languages: readonly string[];
|
|
291
|
+
/** The zone the workspace's times of day are read in; `UTC` where none is set, as `timeZoneSet` says. */
|
|
253
292
|
readonly timeZone: string;
|
|
293
|
+
/**
|
|
294
|
+
* Whether an administrator set the time zone: `false` where the workspace runs on UTC because
|
|
295
|
+
* nobody said otherwise, so that a plugin that reads a time of day says so, instead of taking
|
|
296
|
+
* UTC for a choice.
|
|
297
|
+
*/
|
|
298
|
+
readonly timeZoneSet: boolean;
|
|
254
299
|
/** The day the week begins on (ADR-0035): `auto` leaves it to the language of whoever reads. */
|
|
255
300
|
readonly weekStart: WeekStart;
|
|
301
|
+
/**
|
|
302
|
+
* The console's public origin (`PUBLIC_URL`), for a link to the console in what a plugin
|
|
303
|
+
* writes elsewhere; `null` where the deployment names none.
|
|
304
|
+
*/
|
|
305
|
+
readonly url: string | null;
|
|
256
306
|
readonly categories: readonly {
|
|
257
307
|
readonly key: string;
|
|
258
308
|
readonly name: string;
|
|
@@ -260,6 +310,13 @@ export interface PluginWorkspace {
|
|
|
260
310
|
}[];
|
|
261
311
|
}
|
|
262
312
|
|
|
313
|
+
/** An answer given for a person (ADR-0072): the option by its key or by its label, one of the two, and a note. */
|
|
314
|
+
export interface DecisionAnswer {
|
|
315
|
+
readonly optionKey?: string;
|
|
316
|
+
readonly option?: string;
|
|
317
|
+
readonly note?: string;
|
|
318
|
+
}
|
|
319
|
+
|
|
263
320
|
export interface DecisionAsk {
|
|
264
321
|
/** In the language of whoever is to answer, as `people.get` says it (ADR-0065). */
|
|
265
322
|
readonly question: string;
|
|
@@ -287,11 +344,18 @@ export interface PluginConsole {
|
|
|
287
344
|
get(id: string): Promise<RestPerson | null>;
|
|
288
345
|
/**
|
|
289
346
|
* The active people a role of the plugin was granted to, by name, through a project or with
|
|
290
|
-
* everyone, and everyone for `defaultRole` (ADR-0063)
|
|
291
|
-
*
|
|
347
|
+
* everyone, and everyone for `defaultRole` (ADR-0063); for a role that follows the reporting
|
|
348
|
+
* line, whoever manages someone besides (ADR-0068). An administrator holds every role, and is
|
|
349
|
+
* listed only where they were granted it or manage someone.
|
|
292
350
|
*/
|
|
293
351
|
granted(role: string): Promise<readonly RestPerson[]>;
|
|
294
352
|
};
|
|
353
|
+
/** The teams of the workspace (ADR-0073): a name and its members, among the people the caller may see. */
|
|
354
|
+
teams: {
|
|
355
|
+
list(): Promise<readonly RestTeam[]>;
|
|
356
|
+
/** One team by its key (ADR-0074), what a grant and a person's `teams` name it by. */
|
|
357
|
+
get(key: string): Promise<RestTeam | null>;
|
|
358
|
+
};
|
|
295
359
|
projects: {
|
|
296
360
|
list(): Promise<readonly RestProject[]>;
|
|
297
361
|
get(key: string): Promise<RestProject | null>;
|
|
@@ -308,6 +372,14 @@ export interface PluginConsole {
|
|
|
308
372
|
list(project: string): Promise<readonly RestDecision[]>;
|
|
309
373
|
get(id: string): Promise<RestDecision | null>;
|
|
310
374
|
ask(project: string, decision: DecisionAsk): Promise<RestDecision>;
|
|
375
|
+
/**
|
|
376
|
+
* Answers a decision for a person who answered elsewhere (ADR-0072), with `people:act`: one on
|
|
377
|
+
* the person's own plate, addressed to them or a plugin's addressed to someone they manage, up
|
|
378
|
+
* the reporting line (ADR-0068), and `plugin.forbidden` for any other, whatever they
|
|
379
|
+
* administer or are a member of. The answer is theirs, through this plugin. A plugin answers
|
|
380
|
+
* no decision itself.
|
|
381
|
+
*/
|
|
382
|
+
answer(id: string, answer: DecisionAnswer, as: { readonly for: string }): Promise<RestDecision>;
|
|
311
383
|
};
|
|
312
384
|
documents: {
|
|
313
385
|
list(project: string, folder?: string): Promise<readonly RestDocumentSummary[]>;
|
|
@@ -342,28 +414,90 @@ export interface PluginSql extends PluginSqlClient {
|
|
|
342
414
|
transaction<T>(work: (tx: PluginSqlClient) => Promise<T>): Promise<T>;
|
|
343
415
|
}
|
|
344
416
|
|
|
345
|
-
/**
|
|
346
|
-
|
|
417
|
+
/**
|
|
418
|
+
* Where the console puts a secret into an outbound request: a header, as `Authorization: Bearer`,
|
|
419
|
+
* a query parameter, as basic authentication (`user:password`), or a field of a JSON or a form
|
|
420
|
+
* body.
|
|
421
|
+
*/
|
|
422
|
+
export type SecretPlacement =
|
|
423
|
+
| `header:${string}`
|
|
424
|
+
| "bearer"
|
|
425
|
+
| `query:${string}`
|
|
426
|
+
| "basic"
|
|
427
|
+
| `body:${string}`;
|
|
428
|
+
|
|
429
|
+
/** One secret setting and where it goes. */
|
|
430
|
+
export interface SecretUse {
|
|
431
|
+
readonly name: string;
|
|
432
|
+
readonly as: SecretPlacement;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* A value of the answer to seal as a secret setting (ADR-0070): the path to it in the answer's
|
|
437
|
+
* JSON (`$.access_token`, `$.credentials.signing_secret`, `$.items.0.token`), and, where it
|
|
438
|
+
* expires, how long it lasts, in seconds or as a path to the seconds (`$.expires_in`). The
|
|
439
|
+
* console seals the value and hands the plugin the answer with `"[sealed]"` in its place.
|
|
440
|
+
*/
|
|
441
|
+
export type SecretSeal =
|
|
442
|
+
| string
|
|
443
|
+
| { readonly path: string; readonly expiresIn?: number | string | undefined };
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* What became of a value `seal` named (ADR-0070). `sealed`: sealed as the setting, the answer
|
|
447
|
+
* saying `"[sealed]"` in its place. `absent`: nothing at the path (no such key, `null` or an
|
|
448
|
+
* empty string); nothing sealed, nothing taken out. `not_a_string`, `too_long` and `echoed`: a
|
|
449
|
+
* value that is no string, is over the bound of a secret (4,096 characters), or holds a secret
|
|
450
|
+
* the request carried from another setting; never sealed and never handed over, the answer saying
|
|
451
|
+
* `"[withheld]"` in its place. `not_json`: an answer that is no JSON object or array, whose body
|
|
452
|
+
* is withheld whole. Only `sealed` changed the setting.
|
|
453
|
+
*/
|
|
454
|
+
export type SealOutcome = "sealed" | "absent" | "not_a_string" | "too_long" | "echoed" | "not_json";
|
|
347
455
|
|
|
348
456
|
export interface PluginFetchInit {
|
|
457
|
+
/** `GET` unless said; `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE` or `OPTIONS`, and no other. */
|
|
349
458
|
readonly method?: string;
|
|
350
459
|
readonly headers?: Readonly<Record<string, string>>;
|
|
351
460
|
readonly body?: string | Uint8Array;
|
|
352
|
-
/** A secret setting, put where `as` says
|
|
353
|
-
readonly secret?:
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
/**
|
|
461
|
+
/** A secret setting, put where `as` says. */
|
|
462
|
+
readonly secret?: SecretUse;
|
|
463
|
+
/** Several, where a request carries more than one: a client secret in the body and a token as a bearer, say. */
|
|
464
|
+
readonly secrets?: readonly SecretUse[];
|
|
465
|
+
/** Values of the answer to seal as secret settings, by the setting's name, never handed to the plugin. */
|
|
466
|
+
readonly seal?: Readonly<Record<string, SecretSeal>>;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* An outbound answer, whole: the host reads it to the bound before handing it over, a redirect as
|
|
471
|
+
* it is, never followed. Every form of a secret the request carried, and of a value sealed or
|
|
472
|
+
* withheld, is taken out of its headers and its body first, `"[withheld]"` in its place.
|
|
473
|
+
*/
|
|
357
474
|
export interface PluginFetchResponse {
|
|
358
475
|
readonly status: number;
|
|
359
476
|
readonly headers: Readonly<Record<string, string>>;
|
|
360
477
|
readonly bytes: Uint8Array;
|
|
478
|
+
/** What became of each value `seal` named, by the setting's name; there where the request named `seal`. */
|
|
479
|
+
readonly sealed?: Readonly<Record<string, SealOutcome>>;
|
|
361
480
|
text(): string;
|
|
362
481
|
json(): unknown;
|
|
363
482
|
}
|
|
364
483
|
|
|
484
|
+
/** How a secret stands: whether it is set and not expired, when it expires, and where it came from. */
|
|
485
|
+
export interface SecretStatus {
|
|
486
|
+
/** Set, and not past its expiry. */
|
|
487
|
+
readonly set: boolean;
|
|
488
|
+
readonly setAt: string | null;
|
|
489
|
+
/** When it expires, or `null` for a secret that does not. */
|
|
490
|
+
readonly expiresAt: string | null;
|
|
491
|
+
/** `administrator` for one an administrator set; the host of the answer it was sealed from otherwise; `null` while unset. */
|
|
492
|
+
readonly source: string | null;
|
|
493
|
+
}
|
|
494
|
+
|
|
365
495
|
export interface PluginSecrets {
|
|
496
|
+
/** Whether the secret is set and not past its expiry. */
|
|
366
497
|
has(name: string): Promise<boolean>;
|
|
498
|
+
/** How the secret stands, for a settings page to say and a job to decide whether to renew it. */
|
|
499
|
+
status(name: string): Promise<SecretStatus>;
|
|
500
|
+
/** Signs with the secret; refused for a secret declared `use: "verify"`, which signs nothing. */
|
|
367
501
|
sign(name: string, payload: string | Uint8Array, algorithm: "hmac-sha256"): Promise<string>;
|
|
368
502
|
verify(
|
|
369
503
|
name: string,
|
|
@@ -376,6 +510,23 @@ export interface PluginSecrets {
|
|
|
376
510
|
export interface OtherPlugin {
|
|
377
511
|
readonly version: string;
|
|
378
512
|
readonly api: Readonly<Record<string, (input: unknown) => Promise<unknown>>>;
|
|
513
|
+
/**
|
|
514
|
+
* The same plugin, called for a person who did something elsewhere (ADR-0072): with the roles
|
|
515
|
+
* this plugin and the person both hold in it, in the person's language, what it reads of the
|
|
516
|
+
* console the person's as a member's, whatever they administer, recorded as theirs through this
|
|
517
|
+
* plugin. Needs `people:act`; the person is active, or the call is `plugin.not_found`.
|
|
518
|
+
*/
|
|
519
|
+
for(personId: string): OtherPlugin;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** The plugin's own endpoints, as a service elsewhere is told to call them. */
|
|
523
|
+
export interface PluginEndpoints {
|
|
524
|
+
/**
|
|
525
|
+
* The full address of an endpoint the plugin defines, under the console's public origin, to
|
|
526
|
+
* register a callback or a webhook with a service elsewhere; `plugin.not_found` for a name the
|
|
527
|
+
* plugin does not define, `plugin.unavailable` where the console has no public address.
|
|
528
|
+
*/
|
|
529
|
+
url(name: string): Promise<string>;
|
|
379
530
|
}
|
|
380
531
|
|
|
381
532
|
export interface PluginLog {
|
|
@@ -399,6 +550,7 @@ export interface PluginContext {
|
|
|
399
550
|
fetch(url: string, init?: PluginFetchInit): Promise<PluginFetchResponse>;
|
|
400
551
|
readonly secrets: PluginSecrets;
|
|
401
552
|
readonly plugins: { get(key: string): Promise<OtherPlugin> };
|
|
553
|
+
readonly endpoints: PluginEndpoints;
|
|
402
554
|
readonly log: PluginLog;
|
|
403
555
|
now(): Date;
|
|
404
556
|
/** The caller's language, `en` for the console. */
|
|
@@ -410,6 +562,14 @@ export interface PluginContext {
|
|
|
410
562
|
/** An event a hook receives: the console's, of the plugin's board projects, or another plugin's. */
|
|
411
563
|
export interface PluginHookEvent {
|
|
412
564
|
readonly source: "console" | "plugin";
|
|
565
|
+
/**
|
|
566
|
+
* The event's id, one per event across both streams (`console:<number>` or `plugin:<number>`):
|
|
567
|
+
* delivery is at least once, so a hook that must not act twice keeps the ids it acted on and
|
|
568
|
+
* passes over one it has seen.
|
|
569
|
+
*/
|
|
570
|
+
readonly id: string;
|
|
571
|
+
/** Which delivery of the event this is: 1 the first time, more after a failure and its retries. */
|
|
572
|
+
readonly attempt: number;
|
|
413
573
|
/** `console:<kind>` or `plugin:<key>:<kind>`, as the hook was named. */
|
|
414
574
|
readonly kind: string;
|
|
415
575
|
readonly at: string;
|
package/src/plugin/define.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { ArgDefinitions, ArgDescription, ArgsOf } from "./args.js";
|
|
7
7
|
import type {
|
|
8
8
|
CollectionDefinition,
|
|
9
|
+
CollectionRetention,
|
|
9
10
|
EndpointRequest,
|
|
10
11
|
EndpointResponse,
|
|
11
12
|
PluginContext,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
MAX_PLUGIN_ENDPOINTS,
|
|
20
21
|
MAX_PLUGIN_INDEXES,
|
|
21
22
|
MAX_PLUGIN_OPERATIONS,
|
|
23
|
+
MAX_PLUGIN_RETENTION_DAYS,
|
|
22
24
|
MIN_PLUGIN_JOB_INTERVAL_MS,
|
|
23
25
|
} from "./limits.js";
|
|
24
26
|
import {
|
|
@@ -103,6 +105,13 @@ export interface CollectionInput<S extends RecordSchema> {
|
|
|
103
105
|
readonly readOwn?: readonly string[];
|
|
104
106
|
/** A version kept per write; on unless said otherwise. */
|
|
105
107
|
readonly history?: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* How long a record is kept: `days` (1 to `MAX_PLUGIN_RETENTION_DAYS`) after the moment `by`
|
|
110
|
+
* names, `createdAt` unless said, `updatedAt`, or an indexed field holding a day or an instant
|
|
111
|
+
* as ISO 8601 text. The console removes what is past it, with its versions, links and files;
|
|
112
|
+
* with `history: false`, a light collection for logs and deliveries. Kept for ever when left out.
|
|
113
|
+
*/
|
|
114
|
+
readonly retention?: { readonly days: number; readonly by?: string };
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
/** A collection of records the console keeps for the plugin, by name. */
|
|
@@ -154,6 +163,24 @@ export function defineCollection<S extends RecordSchema>(
|
|
|
154
163
|
if (input.readOwn !== undefined && input.owner === undefined) {
|
|
155
164
|
problems.push(`the collection ${name} has readOwn roles and no owner field`);
|
|
156
165
|
}
|
|
166
|
+
const retention = input.retention;
|
|
167
|
+
if (retention !== undefined) {
|
|
168
|
+
if (
|
|
169
|
+
!Number.isInteger(retention.days) ||
|
|
170
|
+
retention.days < 1 ||
|
|
171
|
+
retention.days > MAX_PLUGIN_RETENTION_DAYS
|
|
172
|
+
) {
|
|
173
|
+
problems.push(
|
|
174
|
+
`the collection ${name} keeps records for ${String(retention.days)} days; 1 to ${MAX_PLUGIN_RETENTION_DAYS}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const by = retention.by ?? "createdAt";
|
|
178
|
+
if (by !== "createdAt" && by !== "updatedAt" && !indexes.includes(by)) {
|
|
179
|
+
problems.push(
|
|
180
|
+
`the collection ${name} keeps records by ${by}, which is neither createdAt, updatedAt nor an indexed field`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
157
184
|
fail(problems);
|
|
158
185
|
return Object.freeze({
|
|
159
186
|
name,
|
|
@@ -165,6 +192,10 @@ export function defineCollection<S extends RecordSchema>(
|
|
|
165
192
|
read: Object.freeze([...(input.read ?? [])]),
|
|
166
193
|
readOwn: Object.freeze([...(input.readOwn ?? [])]),
|
|
167
194
|
history: input.history ?? true,
|
|
195
|
+
retention:
|
|
196
|
+
input.retention === undefined
|
|
197
|
+
? null
|
|
198
|
+
: Object.freeze({ days: input.retention.days, by: input.retention.by ?? "createdAt" }),
|
|
168
199
|
});
|
|
169
200
|
}
|
|
170
201
|
|
|
@@ -595,6 +626,8 @@ export interface CollectionDescription {
|
|
|
595
626
|
readonly read: readonly string[];
|
|
596
627
|
readonly readOwn: readonly string[];
|
|
597
628
|
readonly history: boolean;
|
|
629
|
+
/** How long a record is kept, or `null` for as long as the plugin is; none from a plugin built before it. */
|
|
630
|
+
readonly retention?: CollectionRetention | null | undefined;
|
|
598
631
|
}
|
|
599
632
|
|
|
600
633
|
export interface EndpointDescription {
|
|
@@ -670,6 +703,7 @@ export function describePlugin(definition: PluginDefinition): PluginDescription
|
|
|
670
703
|
read: [...collection.read],
|
|
671
704
|
readOwn: [...collection.readOwn],
|
|
672
705
|
history: collection.history,
|
|
706
|
+
retention: collection.retention ?? null,
|
|
673
707
|
})),
|
|
674
708
|
commands: Object.fromEntries(
|
|
675
709
|
Object.entries(definition.commands).map(([name, command]) => [
|
package/src/plugin/limits.ts
CHANGED
|
@@ -10,6 +10,8 @@ export const MAX_PLUGIN_AGENT_NOTE_LENGTH = 200;
|
|
|
10
10
|
export const MAX_PLUGIN_KEY_LENGTH = 32;
|
|
11
11
|
/** How many roles, settings, groups of settings, pages and widgets one manifest declares. */
|
|
12
12
|
export const MAX_PLUGIN_ROLES = 10;
|
|
13
|
+
/** How many other plugins one plugin may ask roles of (ADR-0072). */
|
|
14
|
+
export const MAX_PLUGIN_GRANTS_ASKED = 10;
|
|
13
15
|
export const MAX_PLUGIN_SETTINGS = 50;
|
|
14
16
|
export const MAX_PLUGIN_SETTING_GROUPS = 10;
|
|
15
17
|
export const MAX_PLUGIN_PAGES = 12;
|
|
@@ -42,6 +44,8 @@ export const MAX_PLUGIN_COMMAND_FILES = 20;
|
|
|
42
44
|
export const MAX_PLUGIN_EVENT_BYTES = 16 * 1024;
|
|
43
45
|
export const PLUGIN_EVENTS_KEPT = 100_000;
|
|
44
46
|
export const PLUGIN_EVENT_DAYS = 90;
|
|
47
|
+
/** The most days a collection's retention may keep a record: ten years. */
|
|
48
|
+
export const MAX_PLUGIN_RETENTION_DAYS = 3650;
|
|
45
49
|
/** How many records one page of a list carries at most. */
|
|
46
50
|
export const PLUGIN_LIST_LIMIT = 200;
|
|
47
51
|
/** How long plugin code may run: a command; a query or an endpoint; a hook; a job; `onEnable`, `onDisable` and an upgrade. */
|
package/src/plugin/manifest.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
MAX_PLUGIN_AGENT_NOTE_LENGTH,
|
|
15
15
|
MAX_PLUGIN_CAPABILITIES,
|
|
16
16
|
MAX_PLUGIN_DESCRIPTION_LENGTH,
|
|
17
|
+
MAX_PLUGIN_GRANTS_ASKED,
|
|
17
18
|
MAX_PLUGIN_NAME_LENGTH,
|
|
18
19
|
MAX_PLUGIN_OPTIONS,
|
|
19
20
|
MAX_PLUGIN_PAGES,
|
|
@@ -212,7 +213,28 @@ export const PLUGIN_BOARD_NEEDS = ["required", "optional", "none"] as const;
|
|
|
212
213
|
export type PluginBoardNeeds = (typeof PLUGIN_BOARD_NEEDS)[number];
|
|
213
214
|
|
|
214
215
|
const LANGUAGE_TAG = /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
|
|
215
|
-
const DAY =
|
|
216
|
+
const DAY = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* True for a day as `YYYY-MM-DD` that the calendar has: February 30 and April 31 fit the shape
|
|
220
|
+
* and are no day, and a date made from them comes back as another day.
|
|
221
|
+
*/
|
|
222
|
+
export function isCalendarDay(value: unknown): value is string {
|
|
223
|
+
if (typeof value !== "string") {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
const match = DAY.exec(value);
|
|
227
|
+
if (match === null) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
const year = Number(match[1]);
|
|
231
|
+
const month = Number(match[2]);
|
|
232
|
+
const day = Number(match[3]);
|
|
233
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
234
|
+
return (
|
|
235
|
+
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
|
236
|
+
);
|
|
237
|
+
}
|
|
216
238
|
/** A time of day as `HH:MM`, read in the workspace's time zone. */
|
|
217
239
|
export const TIME_OF_DAY = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
|
218
240
|
/** A path within the package: relative, forward slashes, never climbing out. */
|
|
@@ -290,6 +312,10 @@ const pluginSettingSchema = z
|
|
|
290
312
|
of: z.optional(settingItemType),
|
|
291
313
|
/** The group of `settingGroups` the setting is shown under; left out, above every group. */
|
|
292
314
|
group: z.optional(key),
|
|
315
|
+
/** For a `secret`: the hosts it may be put into a request to and sealed from the answers of (`slack.com`, `*.example.com`), and no other; any host the plugin may reach when left out. */
|
|
316
|
+
hosts: z.optional(z.array(z.string().check(z.maxLength(300))).check(z.maxLength(20))),
|
|
317
|
+
/** For a `secret`: `verify`, a secret that verifies signatures and signs nothing, so that a request cannot be forged with it. */
|
|
318
|
+
use: z.optional(z.literal("verify")),
|
|
293
319
|
})
|
|
294
320
|
.check(
|
|
295
321
|
z.superRefine((setting, ctx) => {
|
|
@@ -327,7 +353,7 @@ function fitsSettingType(
|
|
|
327
353
|
case "enum":
|
|
328
354
|
return typeof value === "string" && (options ?? []).includes(value);
|
|
329
355
|
case "day":
|
|
330
|
-
return
|
|
356
|
+
return isCalendarDay(value);
|
|
331
357
|
case "time":
|
|
332
358
|
return typeof value === "string" && TIME_OF_DAY.test(value);
|
|
333
359
|
case "person":
|
|
@@ -345,8 +371,18 @@ function settingProblems(setting: {
|
|
|
345
371
|
max?: number;
|
|
346
372
|
options?: readonly { value: string }[];
|
|
347
373
|
of?: Exclude<PluginSettingType, "secret" | "list">;
|
|
374
|
+
hosts?: readonly string[];
|
|
375
|
+
use?: "verify";
|
|
348
376
|
}): string[] {
|
|
349
377
|
const problems: string[] = [];
|
|
378
|
+
if (setting.type !== "secret" && (setting.hosts !== undefined || setting.use !== undefined)) {
|
|
379
|
+
problems.push(`only a secret takes hosts or use`);
|
|
380
|
+
}
|
|
381
|
+
for (const host of setting.hosts ?? []) {
|
|
382
|
+
if (!isPluginCapability(`outbound:${host}`) || host === "webrtc") {
|
|
383
|
+
problems.push(`the host "${host}" is not a host as outbound:<host> names one`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
350
386
|
const bounded =
|
|
351
387
|
setting.type === "number" ||
|
|
352
388
|
setting.type === "text" ||
|
|
@@ -503,6 +539,11 @@ export type PluginBoard = z.infer<typeof pluginBoardSchema>;
|
|
|
503
539
|
const pluginRoleSchema = z.strictObject({
|
|
504
540
|
name: localized(MAX_PLUGIN_NAME_LENGTH),
|
|
505
541
|
description: localized(MAX_PLUGIN_DESCRIPTION_LENGTH),
|
|
542
|
+
/**
|
|
543
|
+
* A role held by what a person is in the directory, with no grant (ADR-0068): `reports`,
|
|
544
|
+
* whoever someone active reports to, read from the directory on every call.
|
|
545
|
+
*/
|
|
546
|
+
follows: z.optional(z.literal("reports")),
|
|
506
547
|
});
|
|
507
548
|
|
|
508
549
|
export type PluginRole = z.infer<typeof pluginRoleSchema>;
|
|
@@ -545,6 +586,20 @@ export const pluginManifestSchema = z
|
|
|
545
586
|
z.record(key, z.string().check(z.regex(SEMVER_RANGE, "is a semver range"))),
|
|
546
587
|
{},
|
|
547
588
|
),
|
|
589
|
+
/**
|
|
590
|
+
* The roles of other plugins the plugin asks to hold (ADR-0072), by their key, each with why,
|
|
591
|
+
* for the administrator who grants them: shown when enabling and on the plugin's page.
|
|
592
|
+
*/
|
|
593
|
+
grants: z._default(
|
|
594
|
+
z.record(
|
|
595
|
+
key,
|
|
596
|
+
z.strictObject({
|
|
597
|
+
roles: z.array(key).check(z.minLength(1), z.maxLength(MAX_PLUGIN_ROLES)),
|
|
598
|
+
why: localized(MAX_PLUGIN_DESCRIPTION_LENGTH),
|
|
599
|
+
}),
|
|
600
|
+
),
|
|
601
|
+
{},
|
|
602
|
+
),
|
|
548
603
|
board: z._default(pluginBoardSchema, { needs: "none" }),
|
|
549
604
|
settings: z._default(z.array(pluginSettingSchema).check(z.maxLength(MAX_PLUGIN_SETTINGS)), []),
|
|
550
605
|
settingGroups: z._default(
|
|
@@ -559,7 +614,17 @@ export const pluginManifestSchema = z
|
|
|
559
614
|
})
|
|
560
615
|
.check(
|
|
561
616
|
z.superRefine((manifest, ctx) => {
|
|
562
|
-
for (const problem of
|
|
617
|
+
for (const problem of [
|
|
618
|
+
...manifestProblems(manifest),
|
|
619
|
+
...(manifest.key in manifest.grants ? ["grants: a plugin asks nothing of itself"] : []),
|
|
620
|
+
// Its own api through `ctx.plugins` would be itself called as another plugin, and, with
|
|
621
|
+
// `people:act`, every call of its own made for anyone (ADR-0072).
|
|
622
|
+
...(capabilitiesAsked(manifest).includes(`plugins:${manifest.key}`)
|
|
623
|
+
? [
|
|
624
|
+
`capabilities: plugins:${manifest.key} is the plugin itself; a plugin calls no api of its own`,
|
|
625
|
+
]
|
|
626
|
+
: []),
|
|
627
|
+
]) {
|
|
563
628
|
ctx.addIssue({ code: "custom", message: problem, input: manifest });
|
|
564
629
|
}
|
|
565
630
|
}),
|
|
@@ -588,6 +653,12 @@ function manifestProblems(
|
|
|
588
653
|
if (roles.length > MAX_PLUGIN_ROLES) {
|
|
589
654
|
problems.push(`declares ${roles.length} roles; ${MAX_PLUGIN_ROLES} at most`);
|
|
590
655
|
}
|
|
656
|
+
const pluginsAsked = Object.keys(manifest.grants);
|
|
657
|
+
if (pluginsAsked.length > MAX_PLUGIN_GRANTS_ASKED) {
|
|
658
|
+
problems.push(
|
|
659
|
+
`asks roles of ${pluginsAsked.length} plugins; ${MAX_PLUGIN_GRANTS_ASKED} at most`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
591
662
|
if (manifest.defaultRole !== undefined && !roles.includes(manifest.defaultRole)) {
|
|
592
663
|
problems.push(`defaultRole ${manifest.defaultRole} is not a declared role`);
|
|
593
664
|
}
|