@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.
Files changed (192) hide show
  1. package/README.md +3 -3
  2. package/dist/api.d.ts +3 -3
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +3 -3
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +162 -15
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +16 -3
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +68 -19
  12. package/dist/cli/format.js.map +1 -1
  13. package/dist/cli/help.d.ts.map +1 -1
  14. package/dist/cli/help.js +26 -5
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/client.d.ts +28 -1
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +12 -2
  19. package/dist/client.js.map +1 -1
  20. package/dist/compat.js +1 -1
  21. package/dist/components/board.d.ts.map +1 -1
  22. package/dist/components/board.js +46 -15
  23. package/dist/components/board.js.map +1 -1
  24. package/dist/components/decision-form.d.ts +0 -3
  25. package/dist/components/decision-form.d.ts.map +1 -1
  26. package/dist/components/decision-form.js +3 -15
  27. package/dist/components/decision-form.js.map +1 -1
  28. package/dist/components/event-feed.d.ts.map +1 -1
  29. package/dist/components/event-feed.js +30 -10
  30. package/dist/components/event-feed.js.map +1 -1
  31. package/dist/components/my-decisions.d.ts +20 -3
  32. package/dist/components/my-decisions.d.ts.map +1 -1
  33. package/dist/components/my-decisions.js +14 -1
  34. package/dist/components/my-decisions.js.map +1 -1
  35. package/dist/components/people.d.ts +43 -1
  36. package/dist/components/people.d.ts.map +1 -1
  37. package/dist/components/people.js +117 -9
  38. package/dist/components/people.js.map +1 -1
  39. package/dist/components/picker.d.ts +8 -0
  40. package/dist/components/picker.d.ts.map +1 -1
  41. package/dist/components/picker.js +2 -2
  42. package/dist/components/picker.js.map +1 -1
  43. package/dist/components/plugin-frame.d.ts +0 -1
  44. package/dist/components/plugin-frame.d.ts.map +1 -1
  45. package/dist/components/plugin-frame.js +22 -1
  46. package/dist/components/plugin-frame.js.map +1 -1
  47. package/dist/components/plugin-settings.d.ts +5 -1
  48. package/dist/components/plugin-settings.d.ts.map +1 -1
  49. package/dist/components/plugin-settings.js +143 -30
  50. package/dist/components/plugin-settings.js.map +1 -1
  51. package/dist/components/projects.d.ts.map +1 -1
  52. package/dist/components/projects.js +3 -1
  53. package/dist/components/projects.js.map +1 -1
  54. package/dist/components/searches.d.ts +11 -0
  55. package/dist/components/searches.d.ts.map +1 -0
  56. package/dist/components/searches.js +30 -0
  57. package/dist/components/searches.js.map +1 -0
  58. package/dist/components/task-form.d.ts.map +1 -1
  59. package/dist/components/task-form.js +6 -5
  60. package/dist/components/task-form.js.map +1 -1
  61. package/dist/components/ui.d.ts +16 -0
  62. package/dist/components/ui.d.ts.map +1 -1
  63. package/dist/components/ui.js +10 -0
  64. package/dist/components/ui.js.map +1 -1
  65. package/dist/console.css +362 -67
  66. package/dist/console.d.ts +2 -1
  67. package/dist/console.d.ts.map +1 -1
  68. package/dist/console.js +90 -14
  69. package/dist/console.js.map +1 -1
  70. package/dist/data.d.ts +14 -1
  71. package/dist/data.d.ts.map +1 -1
  72. package/dist/data.js +36 -1
  73. package/dist/data.js.map +1 -1
  74. package/dist/i18n/en.d.ts +89 -0
  75. package/dist/i18n/en.d.ts.map +1 -1
  76. package/dist/i18n/en.js +87 -2
  77. package/dist/i18n/en.js.map +1 -1
  78. package/dist/i18n/ko.d.ts.map +1 -1
  79. package/dist/i18n/ko.js +87 -3
  80. package/dist/i18n/ko.js.map +1 -1
  81. package/dist/index.d.ts +3 -2
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +3 -2
  84. package/dist/index.js.map +1 -1
  85. package/dist/limits.d.ts +6 -0
  86. package/dist/limits.d.ts.map +1 -1
  87. package/dist/limits.js +6 -0
  88. package/dist/limits.js.map +1 -1
  89. package/dist/plugin/args.d.ts +6 -1
  90. package/dist/plugin/args.d.ts.map +1 -1
  91. package/dist/plugin/args.js +16 -11
  92. package/dist/plugin/args.js.map +1 -1
  93. package/dist/plugin/bridge.d.ts +20 -1
  94. package/dist/plugin/bridge.d.ts.map +1 -1
  95. package/dist/plugin/bridge.js +28 -0
  96. package/dist/plugin/bridge.js.map +1 -1
  97. package/dist/plugin/capabilities.d.ts +1 -1
  98. package/dist/plugin/capabilities.d.ts.map +1 -1
  99. package/dist/plugin/capabilities.js +10 -0
  100. package/dist/plugin/capabilities.js.map +1 -1
  101. package/dist/plugin/context.d.ts +158 -13
  102. package/dist/plugin/context.d.ts.map +1 -1
  103. package/dist/plugin/define.d.ts +13 -1
  104. package/dist/plugin/define.d.ts.map +1 -1
  105. package/dist/plugin/define.js +17 -1
  106. package/dist/plugin/define.js.map +1 -1
  107. package/dist/plugin/limits.d.ts +4 -0
  108. package/dist/plugin/limits.d.ts.map +1 -1
  109. package/dist/plugin/limits.js +4 -0
  110. package/dist/plugin/limits.js.map +1 -1
  111. package/dist/plugin/manifest.d.ts +15 -0
  112. package/dist/plugin/manifest.d.ts.map +1 -1
  113. package/dist/plugin/manifest.js +61 -4
  114. package/dist/plugin/manifest.js.map +1 -1
  115. package/dist/plugin/rest-schemas.d.ts +44 -0
  116. package/dist/plugin/rest-schemas.d.ts.map +1 -1
  117. package/dist/plugin/rest-schemas.js +55 -3
  118. package/dist/plugin/rest-schemas.js.map +1 -1
  119. package/dist/plugin/testing.d.ts +15 -1
  120. package/dist/plugin/testing.d.ts.map +1 -1
  121. package/dist/plugin/testing.js +40 -4
  122. package/dist/plugin/testing.js.map +1 -1
  123. package/dist/plugin/web.d.ts +6 -0
  124. package/dist/plugin/web.d.ts.map +1 -1
  125. package/dist/plugin/web.js +10 -1
  126. package/dist/plugin/web.js.map +1 -1
  127. package/dist/plugin.d.ts +1 -1
  128. package/dist/plugin.d.ts.map +1 -1
  129. package/dist/plugin.js +1 -1
  130. package/dist/plugin.js.map +1 -1
  131. package/dist/router.d.ts +19 -0
  132. package/dist/router.d.ts.map +1 -1
  133. package/dist/router.js +18 -0
  134. package/dist/router.js.map +1 -1
  135. package/dist/routes.d.ts +11 -2
  136. package/dist/routes.d.ts.map +1 -1
  137. package/dist/routes.js +14 -1
  138. package/dist/routes.js.map +1 -1
  139. package/dist/schemas.d.ts +367 -0
  140. package/dist/schemas.d.ts.map +1 -1
  141. package/dist/schemas.js +62 -2
  142. package/dist/schemas.js.map +1 -1
  143. package/dist/vocabulary.d.ts +1 -1
  144. package/dist/vocabulary.d.ts.map +1 -1
  145. package/dist/vocabulary.js +6 -0
  146. package/dist/vocabulary.js.map +1 -1
  147. package/docs/specs/cli.md +7 -4
  148. package/docs/specs/plugins.md +47 -39
  149. package/docs/specs/rest.md +22 -5
  150. package/package.json +1 -1
  151. package/skills/building-a-plugin/SKILL.md +8 -6
  152. package/skills/working-the-board/SKILL.md +1 -1
  153. package/src/api.ts +17 -0
  154. package/src/cli/cli.ts +200 -12
  155. package/src/cli/format.ts +82 -19
  156. package/src/cli/help.ts +30 -5
  157. package/src/client.ts +70 -0
  158. package/src/compat.ts +1 -1
  159. package/src/components/board.tsx +83 -42
  160. package/src/components/decision-form.tsx +13 -32
  161. package/src/components/event-feed.tsx +36 -9
  162. package/src/components/my-decisions.tsx +50 -5
  163. package/src/components/people.tsx +394 -37
  164. package/src/components/picker.tsx +11 -2
  165. package/src/components/plugin-frame.tsx +23 -0
  166. package/src/components/plugin-settings.tsx +346 -137
  167. package/src/components/projects.tsx +14 -14
  168. package/src/components/searches.ts +47 -0
  169. package/src/components/task-form.tsx +23 -28
  170. package/src/components/ui.tsx +36 -0
  171. package/src/console.tsx +229 -40
  172. package/src/data.ts +53 -1
  173. package/src/i18n/en.ts +98 -2
  174. package/src/i18n/ko.ts +95 -3
  175. package/src/index.ts +11 -0
  176. package/src/limits.ts +6 -0
  177. package/src/plugin/args.ts +16 -11
  178. package/src/plugin/bridge.ts +45 -0
  179. package/src/plugin/capabilities.ts +11 -0
  180. package/src/plugin/context.ts +170 -10
  181. package/src/plugin/define.ts +34 -0
  182. package/src/plugin/limits.ts +4 -0
  183. package/src/plugin/manifest.ts +74 -3
  184. package/src/plugin/rest-schemas.ts +62 -5
  185. package/src/plugin/testing.ts +61 -5
  186. package/src/plugin/web.ts +17 -0
  187. package/src/plugin.ts +1 -0
  188. package/src/router.ts +25 -0
  189. package/src/routes.ts +17 -2
  190. package/src/schemas.ts +81 -2
  191. package/src/styles/console.css +362 -67
  192. package/src/vocabulary.ts +6 -0
@@ -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",
@@ -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
- export type PluginCaller = PersonCaller | ConsoleCaller;
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). An administrator holds every role, and
291
- * is listed only where they were granted it.
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
- /** Where the console puts a secret into an outbound request. */
346
- export type SecretPlacement = `header:${string}` | `query:${string}` | "basic";
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: a header, a query parameter, or as basic authentication (`user:password`). */
353
- readonly secret?: { readonly name: string; readonly as: SecretPlacement };
354
- }
355
-
356
- /** An outbound answer, whole: the host reads it to the bound before handing it over. */
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;
@@ -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]) => [
@@ -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. */
@@ -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 = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/;
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 typeof value === "string" && DAY.test(value);
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 manifestProblems(manifest)) {
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
  }