@skillcdn/console 0.4.3 → 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 (268) hide show
  1. package/README.md +6 -6
  2. package/dist/api.d.ts +4 -4
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +4 -4
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +255 -33
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/format.d.ts +20 -5
  10. package/dist/cli/format.d.ts.map +1 -1
  11. package/dist/cli/format.js +98 -26
  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 +39 -15
  15. package/dist/cli/help.js.map +1 -1
  16. package/dist/cli/plugin-check.d.ts +5 -1
  17. package/dist/cli/plugin-check.d.ts.map +1 -1
  18. package/dist/cli/plugin-check.js +23 -4
  19. package/dist/cli/plugin-check.js.map +1 -1
  20. package/dist/cli/plugin-cli.d.ts.map +1 -1
  21. package/dist/cli/plugin-cli.js +10 -1
  22. package/dist/cli/plugin-cli.js.map +1 -1
  23. package/dist/cli/plugin-init.d.ts +2 -0
  24. package/dist/cli/plugin-init.d.ts.map +1 -1
  25. package/dist/cli/plugin-init.js +6 -0
  26. package/dist/cli/plugin-init.js.map +1 -1
  27. package/dist/client.d.ts +32 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +14 -2
  30. package/dist/client.js.map +1 -1
  31. package/dist/compat.js +1 -1
  32. package/dist/components/board.d.ts +8 -0
  33. package/dist/components/board.d.ts.map +1 -1
  34. package/dist/components/board.js +49 -18
  35. package/dist/components/board.js.map +1 -1
  36. package/dist/components/decision-form.d.ts +6 -3
  37. package/dist/components/decision-form.d.ts.map +1 -1
  38. package/dist/components/decision-form.js +24 -18
  39. package/dist/components/decision-form.js.map +1 -1
  40. package/dist/components/decision-list.d.ts +5 -1
  41. package/dist/components/decision-list.d.ts.map +1 -1
  42. package/dist/components/decision-list.js +25 -5
  43. package/dist/components/decision-list.js.map +1 -1
  44. package/dist/components/event-feed.d.ts.map +1 -1
  45. package/dist/components/event-feed.js +34 -8
  46. package/dist/components/event-feed.js.map +1 -1
  47. package/dist/components/goals.d.ts.map +1 -1
  48. package/dist/components/goals.js +2 -2
  49. package/dist/components/goals.js.map +1 -1
  50. package/dist/components/home.d.ts +35 -0
  51. package/dist/components/home.d.ts.map +1 -0
  52. package/dist/components/home.js +326 -0
  53. package/dist/components/home.js.map +1 -0
  54. package/dist/components/my-decisions.d.ts +20 -3
  55. package/dist/components/my-decisions.d.ts.map +1 -1
  56. package/dist/components/my-decisions.js +14 -1
  57. package/dist/components/my-decisions.js.map +1 -1
  58. package/dist/components/pack-connect.d.ts.map +1 -1
  59. package/dist/components/pack-connect.js +5 -4
  60. package/dist/components/pack-connect.js.map +1 -1
  61. package/dist/components/people.d.ts +45 -1
  62. package/dist/components/people.d.ts.map +1 -1
  63. package/dist/components/people.js +125 -4
  64. package/dist/components/people.js.map +1 -1
  65. package/dist/components/picker.d.ts +8 -0
  66. package/dist/components/picker.d.ts.map +1 -1
  67. package/dist/components/picker.js +2 -2
  68. package/dist/components/picker.js.map +1 -1
  69. package/dist/components/plugin-frame.d.ts +3 -13
  70. package/dist/components/plugin-frame.d.ts.map +1 -1
  71. package/dist/components/plugin-frame.js +75 -21
  72. package/dist/components/plugin-frame.js.map +1 -1
  73. package/dist/components/plugin-settings.d.ts +5 -1
  74. package/dist/components/plugin-settings.d.ts.map +1 -1
  75. package/dist/components/plugin-settings.js +283 -45
  76. package/dist/components/plugin-settings.js.map +1 -1
  77. package/dist/components/projects.d.ts +0 -8
  78. package/dist/components/projects.d.ts.map +1 -1
  79. package/dist/components/projects.js +4 -14
  80. package/dist/components/projects.js.map +1 -1
  81. package/dist/components/searches.d.ts +11 -0
  82. package/dist/components/searches.d.ts.map +1 -0
  83. package/dist/components/searches.js +30 -0
  84. package/dist/components/searches.js.map +1 -0
  85. package/dist/components/task-form.d.ts.map +1 -1
  86. package/dist/components/task-form.js +6 -5
  87. package/dist/components/task-form.js.map +1 -1
  88. package/dist/components/task-view.d.ts.map +1 -1
  89. package/dist/components/task-view.js +2 -1
  90. package/dist/components/task-view.js.map +1 -1
  91. package/dist/components/ui.d.ts +16 -0
  92. package/dist/components/ui.d.ts.map +1 -1
  93. package/dist/components/ui.js +15 -2
  94. package/dist/components/ui.js.map +1 -1
  95. package/dist/connect-page.d.ts.map +1 -1
  96. package/dist/connect-page.js +5 -6
  97. package/dist/connect-page.js.map +1 -1
  98. package/dist/console.css +1123 -258
  99. package/dist/console.d.ts +12 -4
  100. package/dist/console.d.ts.map +1 -1
  101. package/dist/console.js +123 -27
  102. package/dist/console.js.map +1 -1
  103. package/dist/data.d.ts +18 -3
  104. package/dist/data.d.ts.map +1 -1
  105. package/dist/data.js +41 -1
  106. package/dist/data.js.map +1 -1
  107. package/dist/docs-page.d.ts.map +1 -1
  108. package/dist/docs-page.js +6 -5
  109. package/dist/docs-page.js.map +1 -1
  110. package/dist/home-tiles.d.ts +92 -0
  111. package/dist/home-tiles.d.ts.map +1 -0
  112. package/dist/home-tiles.js +294 -0
  113. package/dist/home-tiles.js.map +1 -0
  114. package/dist/i18n/en.d.ts +264 -1
  115. package/dist/i18n/en.d.ts.map +1 -1
  116. package/dist/i18n/en.js +260 -1
  117. package/dist/i18n/en.js.map +1 -1
  118. package/dist/i18n/index.d.ts +4 -2
  119. package/dist/i18n/index.d.ts.map +1 -1
  120. package/dist/i18n/index.js +10 -11
  121. package/dist/i18n/index.js.map +1 -1
  122. package/dist/i18n/ko.d.ts.map +1 -1
  123. package/dist/i18n/ko.js +255 -4
  124. package/dist/i18n/ko.js.map +1 -1
  125. package/dist/index.d.ts +7 -5
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +7 -5
  128. package/dist/index.js.map +1 -1
  129. package/dist/limits.d.ts +12 -0
  130. package/dist/limits.d.ts.map +1 -1
  131. package/dist/limits.js +12 -0
  132. package/dist/limits.js.map +1 -1
  133. package/dist/plugin/args.d.ts +6 -1
  134. package/dist/plugin/args.d.ts.map +1 -1
  135. package/dist/plugin/args.js +16 -11
  136. package/dist/plugin/args.js.map +1 -1
  137. package/dist/plugin/bridge.d.ts +63 -3
  138. package/dist/plugin/bridge.d.ts.map +1 -1
  139. package/dist/plugin/bridge.js +54 -0
  140. package/dist/plugin/bridge.js.map +1 -1
  141. package/dist/plugin/capabilities.d.ts +7 -1
  142. package/dist/plugin/capabilities.d.ts.map +1 -1
  143. package/dist/plugin/capabilities.js +24 -1
  144. package/dist/plugin/capabilities.js.map +1 -1
  145. package/dist/plugin/context.d.ts +174 -12
  146. package/dist/plugin/context.d.ts.map +1 -1
  147. package/dist/plugin/define.d.ts +14 -1
  148. package/dist/plugin/define.d.ts.map +1 -1
  149. package/dist/plugin/define.js +18 -2
  150. package/dist/plugin/define.js.map +1 -1
  151. package/dist/plugin/limits.d.ts +9 -2
  152. package/dist/plugin/limits.d.ts.map +1 -1
  153. package/dist/plugin/limits.js +9 -2
  154. package/dist/plugin/limits.js.map +1 -1
  155. package/dist/plugin/manifest.d.ts +98 -6
  156. package/dist/plugin/manifest.d.ts.map +1 -1
  157. package/dist/plugin/manifest.js +171 -10
  158. package/dist/plugin/manifest.js.map +1 -1
  159. package/dist/plugin/rest-schemas.d.ts +120 -0
  160. package/dist/plugin/rest-schemas.d.ts.map +1 -1
  161. package/dist/plugin/rest-schemas.js +78 -5
  162. package/dist/plugin/rest-schemas.js.map +1 -1
  163. package/dist/plugin/sandbox.d.ts +4 -3
  164. package/dist/plugin/sandbox.d.ts.map +1 -1
  165. package/dist/plugin/sandbox.js +33 -6
  166. package/dist/plugin/sandbox.js.map +1 -1
  167. package/dist/plugin/semver.d.ts +26 -0
  168. package/dist/plugin/semver.d.ts.map +1 -0
  169. package/dist/plugin/semver.js +76 -0
  170. package/dist/plugin/semver.js.map +1 -0
  171. package/dist/plugin/testing.d.ts +15 -1
  172. package/dist/plugin/testing.d.ts.map +1 -1
  173. package/dist/plugin/testing.js +42 -5
  174. package/dist/plugin/testing.js.map +1 -1
  175. package/dist/plugin/web.d.ts +16 -1
  176. package/dist/plugin/web.d.ts.map +1 -1
  177. package/dist/plugin/web.js +44 -1
  178. package/dist/plugin/web.js.map +1 -1
  179. package/dist/plugin.d.ts +3 -2
  180. package/dist/plugin.d.ts.map +1 -1
  181. package/dist/plugin.js +3 -2
  182. package/dist/plugin.js.map +1 -1
  183. package/dist/router.d.ts +19 -0
  184. package/dist/router.d.ts.map +1 -1
  185. package/dist/router.js +18 -0
  186. package/dist/router.js.map +1 -1
  187. package/dist/routes.d.ts +11 -2
  188. package/dist/routes.d.ts.map +1 -1
  189. package/dist/routes.js +14 -1
  190. package/dist/routes.js.map +1 -1
  191. package/dist/rules.d.ts +2 -2
  192. package/dist/rules.js +3 -3
  193. package/dist/schemas.d.ts +864 -2
  194. package/dist/schemas.d.ts.map +1 -1
  195. package/dist/schemas.js +156 -9
  196. package/dist/schemas.js.map +1 -1
  197. package/dist/use-action.d.ts +8 -2
  198. package/dist/use-action.d.ts.map +1 -1
  199. package/dist/use-action.js +13 -6
  200. package/dist/use-action.js.map +1 -1
  201. package/dist/vocabulary.d.ts +3 -1
  202. package/dist/vocabulary.d.ts.map +1 -1
  203. package/dist/vocabulary.js +9 -0
  204. package/dist/vocabulary.js.map +1 -1
  205. package/docs/specs/cli.md +10 -6
  206. package/docs/specs/plugins.md +83 -62
  207. package/docs/specs/rest.md +34 -16
  208. package/package.json +1 -1
  209. package/skills/building-a-plugin/SKILL.md +15 -12
  210. package/skills/planning-the-board/references/setting-up.md +2 -1
  211. package/skills/working-the-board/SKILL.md +3 -3
  212. package/src/api.ts +23 -0
  213. package/src/cli/cli.ts +323 -30
  214. package/src/cli/format.ts +122 -26
  215. package/src/cli/help.ts +43 -14
  216. package/src/cli/plugin-check.ts +41 -2
  217. package/src/cli/plugin-cli.ts +10 -1
  218. package/src/cli/plugin-init.ts +8 -0
  219. package/src/client.ts +78 -0
  220. package/src/compat.ts +1 -1
  221. package/src/components/board.tsx +86 -45
  222. package/src/components/decision-form.tsx +73 -36
  223. package/src/components/decision-list.tsx +45 -13
  224. package/src/components/event-feed.tsx +40 -7
  225. package/src/components/goals.tsx +4 -1
  226. package/src/components/home.tsx +615 -0
  227. package/src/components/my-decisions.tsx +50 -5
  228. package/src/components/pack-connect.tsx +5 -4
  229. package/src/components/people.tsx +500 -61
  230. package/src/components/picker.tsx +11 -2
  231. package/src/components/plugin-frame.tsx +85 -63
  232. package/src/components/plugin-settings.tsx +735 -185
  233. package/src/components/projects.tsx +14 -69
  234. package/src/components/searches.ts +47 -0
  235. package/src/components/task-form.tsx +23 -28
  236. package/src/components/task-view.tsx +7 -0
  237. package/src/components/ui.tsx +42 -2
  238. package/src/connect-page.tsx +5 -6
  239. package/src/console.tsx +303 -64
  240. package/src/data.ts +62 -3
  241. package/src/docs-page.tsx +6 -5
  242. package/src/home-tiles.ts +438 -0
  243. package/src/i18n/en.ts +274 -0
  244. package/src/i18n/index.ts +17 -14
  245. package/src/i18n/ko.ts +267 -4
  246. package/src/index.ts +18 -5
  247. package/src/limits.ts +12 -0
  248. package/src/plugin/args.ts +16 -11
  249. package/src/plugin/bridge.ts +122 -1
  250. package/src/plugin/capabilities.ts +28 -1
  251. package/src/plugin/context.ts +186 -9
  252. package/src/plugin/define.ts +38 -1
  253. package/src/plugin/limits.ts +9 -2
  254. package/src/plugin/manifest.ts +228 -13
  255. package/src/plugin/rest-schemas.ts +89 -6
  256. package/src/plugin/sandbox.ts +36 -8
  257. package/src/plugin/semver.ts +89 -0
  258. package/src/plugin/testing.ts +63 -6
  259. package/src/plugin/web.ts +66 -0
  260. package/src/plugin.ts +19 -0
  261. package/src/router.ts +25 -0
  262. package/src/routes.ts +17 -2
  263. package/src/rules.ts +3 -3
  264. package/src/schemas.ts +197 -10
  265. package/src/styles/console.css +1123 -258
  266. package/src/use-action.ts +16 -7
  267. package/src/vocabulary.ts +10 -0
  268. package/template/plugin/console-plugin.json +3 -1
@@ -7,6 +7,7 @@ import {
7
7
  MAX_PROJECT_DESCRIPTION_LENGTH,
8
8
  MAX_PROJECT_KEY_LENGTH,
9
9
  MAX_PROJECT_NAME_LENGTH,
10
+ MAX_TEAM_KEY_LENGTH,
10
11
  } from "../limits.js";
11
12
  import { PLUGIN_STATES } from "../vocabulary.js";
12
13
  import { ARG_TYPES } from "./args.js";
@@ -21,6 +22,7 @@ import {
21
22
  PLUGIN_PAGE_LEVELS,
22
23
  PLUGIN_SETTING_TYPES,
23
24
  PLUGIN_WIDGET_PLACES,
25
+ PLUGIN_WIDGET_SIZE_NAMES,
24
26
  PLUGIN_WIDGET_SIZES,
25
27
  SETTING_KEY_PATTERN,
26
28
  } from "./manifest.js";
@@ -91,7 +93,11 @@ export const restPluginWidgetSchema = z.object({
91
93
  key: z.string(),
92
94
  name: localized,
93
95
  place: z.enum(PLUGIN_WIDGET_PLACES),
96
+ /** The size of before nearest to the one it starts at, for a reader of before. */
94
97
  size: z.enum(PLUGIN_WIDGET_SIZES),
98
+ /** The sizes the widget comes in (ADR-0059) and the one it starts at; answered from 0.4.4 and read as optional. */
99
+ sizes: z.optional(z.array(z.enum(PLUGIN_WIDGET_SIZE_NAMES))),
100
+ defaultSize: z.optional(z.enum(PLUGIN_WIDGET_SIZE_NAMES)),
95
101
  });
96
102
  export type RestPluginWidget = z.infer<typeof restPluginWidgetSchema>;
97
103
 
@@ -103,13 +109,28 @@ export const restPluginSchema = z.object({
103
109
  /** The sentence agents read with the rules, or `null` for the description. */
104
110
  agentNote: z.nullable(localized),
105
111
  version: z.string(),
112
+ /**
113
+ * The consoles the plugin declares it runs on, a semver range of the console's version, or
114
+ * `null` where the manifest names none; a console outside it fails the plugin with the reason
115
+ * in `problems`. Answered from 0.4.4 and read as optional, so that a console built on this
116
+ * package still reads an older console's answer.
117
+ */
118
+ console: z.optional(z.nullable(z.string())),
106
119
  state: z.enum(PLUGIN_STATES),
107
120
  /** Why the plugin failed, or waits for consent; empty otherwise. */
108
121
  problems: z.array(z.string()),
109
122
  /** The asker's roles in the plugin; every role for an administrator. */
110
123
  roles: z.array(z.string()),
111
124
  /** The roles the plugin declares, by key, with their names. */
112
- declaredRoles: z.array(z.object({ key: z.string(), name: localized, description: localized })),
125
+ declaredRoles: z.array(
126
+ z.object({
127
+ key: z.string(),
128
+ name: localized,
129
+ description: localized,
130
+ /** What the role follows, held with no grant: `reports`, whoever manages someone (ADR-0068); answered from 0.4.5 and read as optional. */
131
+ follows: z.optional(z.nullable(z.literal("reports"))),
132
+ }),
133
+ ),
113
134
  /** The role every member holds without a grant, or `null` where the manifest names none. */
114
135
  defaultRole: z.nullable(z.string()),
115
136
  menu: z.nullable(z.object({ group: z.string(), order: z.int(), name: z.nullable(localized) })),
@@ -121,6 +142,8 @@ export const restPluginSchema = z.object({
121
142
  boardNeeds: z.enum(PLUGIN_BOARD_NEEDS),
122
143
  /** What the plugin asks, and what was consented to; `consent` is `null` to a member. */
123
144
  capabilities: z.array(z.string()),
145
+ /** What it can go without (ADR-0060), an administrator's to turn on; a console older than them names none. */
146
+ optionalCapabilities: z.optional(z.array(z.string())),
124
147
  consent: z.nullable(z.array(z.string())),
125
148
  /** The projects the plugin is on: all, their keys, or `null` to a member. */
126
149
  projects: z.nullable(z.union([z.literal("all"), z.array(z.string())])),
@@ -149,11 +172,21 @@ export const restPluginSettingSchema = z.object({
149
172
  max: z.nullable(z.number()),
150
173
  options: z.nullable(z.array(z.object({ value: z.string(), name: localized }))),
151
174
  of: z.nullable(z.string()),
175
+ /** The group the setting is shown under, or `null` for above every group; answered from 0.4.4 and read as optional. */
176
+ group: z.optional(z.nullable(z.string())),
152
177
  /** Whether a value is set; a secret's value is never here. */
153
178
  set: z.boolean(),
154
179
  value: z.nullable(z.unknown()),
155
180
  /** When a secret was set, or `null`. */
156
181
  setAt: z.nullable(instant),
182
+ /** When a secret expires, or `null` (ADR-0070); answered from 0.4.5 and read as optional. */
183
+ expiresAt: z.optional(z.nullable(instant)),
184
+ /** `administrator` for a secret an administrator set, the host of the answer it was sealed from otherwise, `null` while unset. */
185
+ source: z.optional(z.nullable(z.string())),
186
+ /** The hosts a secret is pinned to, or `null` for any the plugin reaches. */
187
+ hosts: z.optional(z.nullable(z.array(z.string()))),
188
+ /** `verify` for a secret that verifies and signs nothing, or `null`. */
189
+ use: z.optional(z.nullable(z.literal("verify"))),
157
190
  });
158
191
  export type RestPluginSetting = z.infer<typeof restPluginSettingSchema>;
159
192
 
@@ -164,6 +197,10 @@ export const restPluginGrantSchema = z.object({
164
197
  person: z.nullable(z.object({ id: uuid, login: z.string(), name: z.nullable(z.string()) })),
165
198
  project: z.nullable(z.object({ key: z.string(), name: z.string() })),
166
199
  everyone: z.boolean(),
200
+ /** The plugin the role went to (ADR-0072), by key, or `null`; answered from 0.4.5 and read as optional. */
201
+ plugin: z.optional(z.nullable(z.string())),
202
+ /** The team the role went to (ADR-0073), whose members hold it, or `null`; answered from 0.4.5 and read as optional. */
203
+ team: z.optional(z.nullable(z.object({ key: z.string(), name: z.string() }))),
167
204
  createdAt: instant,
168
205
  });
169
206
  export type RestPluginGrant = z.infer<typeof restPluginGrantSchema>;
@@ -179,16 +216,42 @@ export const restPluginDetailSchema = z.extend(restPluginSchema, {
179
216
  /** The board project the manifest proposes, for an administrator who makes it; `null` where it proposes none. */
180
217
  boardDefaults: z.nullable(z.object({ key: z.string(), name: localized, description: localized })),
181
218
  settings: z.array(restPluginSettingSchema),
219
+ /** The groups the settings form is divided into, in the manifest's order; answered from 0.4.4 and read as optional. */
220
+ settingGroups: z.optional(
221
+ z.array(z.object({ key: z.string(), name: localized, description: z.nullable(localized) })),
222
+ ),
182
223
  /** The grants, to an administrator; `null` to a member. */
183
224
  grants: z.nullable(z.array(restPluginGrantSchema)),
184
225
  /** The plugin's own events are at this path. */
185
226
  events: z.string(),
227
+ /**
228
+ * Whether the plugin reads a time of day (a job at a time, a `time` setting) while the
229
+ * workspace has no time zone, so that its times run on UTC with nobody having said so: the
230
+ * pages and the command tell an administrator to set one. Answered from 0.4.5 and read as optional.
231
+ */
232
+ needsTimeZone: z.optional(z.boolean()),
233
+ /** The collections the plugin keeps for a while only, each with its days, so that an administrator reads what it forgets; answered from 0.4.5 and read as optional. */
234
+ keeps: z.optional(z.array(z.object({ collection: z.string(), days: z.int() }))),
235
+ /**
236
+ * What the manifest asks of other plugins (ADR-0072): a role of a plugin, why, and how it
237
+ * stands: granted, not yet, or the plugin is not enabled. Answered from 0.4.5 and read as optional.
238
+ */
239
+ asks: z.optional(
240
+ z.array(
241
+ z.object({
242
+ plugin: z.string(),
243
+ role: z.string(),
244
+ why: localized,
245
+ state: z.enum(["granted", "ungranted", "unavailable"]),
246
+ }),
247
+ ),
248
+ ),
186
249
  });
187
250
  export type RestPluginDetail = z.infer<typeof restPluginDetailSchema>;
188
251
 
189
252
  /** What `POST /api/v1/plugins/<key>/enable` is sent. */
190
253
  export const restPluginEnableInputSchema = z.object({
191
- /** Every capability the manifest asks, named again: consent. */
254
+ /** Every capability the manifest requires, named again, and the optional ones to turn on: consent. */
192
255
  consent: z.array(z.string().check(z.maxLength(300))).check(z.maxLength(50)),
193
256
  /** The board project: one to make, one that exists, or none. */
194
257
  board: z.nullable(
@@ -216,6 +279,12 @@ export const restPluginSettingsPatchSchema = z.object({
216
279
  });
217
280
  export type RestPluginSettingsPatch = z.infer<typeof restPluginSettingsPatchSchema>;
218
281
 
282
+ /** What `PATCH /api/v1/plugins/<key>/consent` is sent: what the plugin holds from now on, those it requires named again. */
283
+ export const restPluginConsentPatchSchema = z.object({
284
+ consent: z.array(z.string().check(z.maxLength(300))).check(z.maxLength(50)),
285
+ });
286
+ export type RestPluginConsentPatch = z.infer<typeof restPluginConsentPatchSchema>;
287
+
219
288
  /** What `PUT /api/v1/plugins/<key>/secrets/<name>` is sent. */
220
289
  export const restPluginSecretInputSchema = z.object({
221
290
  value: z.string().check(z.minLength(1), z.maxLength(MAX_PLUGIN_SECRET_LENGTH)),
@@ -228,15 +297,23 @@ export const restPluginGrantInputSchema = z
228
297
  role: pluginKey,
229
298
  person: z.optional(uuid),
230
299
  project: z.optional(projectKey),
300
+ /** A plugin, by key, to hold the role as itself (ADR-0072). */
301
+ plugin: z.optional(pluginKey),
302
+ /** A team, by its key (ADR-0074), whose members hold the role (ADR-0073). */
303
+ team: z.optional(z.string().check(z.minLength(1), z.maxLength(MAX_TEAM_KEY_LENGTH))),
231
304
  everyone: z.optional(z.literal(true)),
232
305
  })
233
306
  .check(
234
307
  z.refine(
235
308
  (input) =>
236
- [input.person !== undefined, input.project !== undefined, input.everyone === true].filter(
237
- Boolean,
238
- ).length === 1,
239
- "names a person, a project or everyone, one of the three",
309
+ [
310
+ input.person !== undefined,
311
+ input.project !== undefined,
312
+ input.plugin !== undefined,
313
+ input.team !== undefined,
314
+ input.everyone === true,
315
+ ].filter(Boolean).length === 1,
316
+ "names a person, a project, a plugin, a team or everyone, one of the five",
240
317
  ),
241
318
  );
242
319
  export type RestPluginGrantInput = z.infer<typeof restPluginGrantInputSchema>;
@@ -285,6 +362,12 @@ export const restPluginInvocationSchema = z.object({
285
362
  text: z.string(),
286
363
  data: z.unknown(),
287
364
  waiting: z.nullable(z.string()),
365
+ /**
366
+ * The key of the project the decision in `waiting` waits in, the one it was raised in, where
367
+ * the caller reaches it, else `null`; with nothing waiting, the plugin's board project's, or
368
+ * `null`. Answered from 0.4.5 and read as optional.
369
+ */
370
+ project: z.optional(z.nullable(z.string())),
288
371
  });
289
372
  export type RestPluginInvocation = z.infer<typeof restPluginInvocationSchema>;
290
373
 
@@ -13,6 +13,15 @@ export const FORBIDDEN_BUILTINS: readonly string[] = [
13
13
  "dgram",
14
14
  "dns",
15
15
  "dns/promises",
16
+ // The same network, under the names Node keeps for compatibility.
17
+ "_http_agent",
18
+ "_http_client",
19
+ "_http_common",
20
+ "_http_incoming",
21
+ "_http_outgoing",
22
+ "_http_server",
23
+ "_tls_common",
24
+ "_tls_wrap",
16
25
  "child_process",
17
26
  "cluster",
18
27
  "worker_threads",
@@ -67,6 +76,15 @@ export interface HardenedThread {
67
76
  readonly removed: readonly string[];
68
77
  }
69
78
 
79
+ /** What a plugin is told of a built-in it may not have, by whichever way it asked. */
80
+ function refusedBuiltin(specifier: string): Error {
81
+ const error = new Error(
82
+ `the host refuses ${specifier}: a plugin reaches the console through ctx alone`,
83
+ );
84
+ (error as { code?: string }).code = "ERR_PLUGIN_FORBIDDEN_BUILTIN";
85
+ return error;
86
+ }
87
+
70
88
  /** The module hook: refuses the forbidden built-ins and lets everything else resolve as it would. */
71
89
  export function forbiddingResolve<C, R>(
72
90
  specifier: string,
@@ -74,19 +92,16 @@ export function forbiddingResolve<C, R>(
74
92
  nextResolve: (specifier: string, context: C) => R,
75
93
  ): R {
76
94
  if (isForbiddenBuiltin(specifier)) {
77
- const error = new Error(
78
- `the host refuses ${specifier}: a plugin reaches the console through ctx alone`,
79
- );
80
- (error as { code?: string }).code = "ERR_PLUGIN_FORBIDDEN_BUILTIN";
81
- throw error;
95
+ throw refusedBuiltin(specifier);
82
96
  }
83
97
  return nextResolve(specifier, context);
84
98
  }
85
99
 
86
100
  /**
87
- * Hardens the current thread: refuses the forbidden built-ins through the module hooks, removes
88
- * the globals, empties the environment, and blocks `process.binding`. Call it in a worker thread
89
- * before importing plugin code; it reaches nothing outside that thread.
101
+ * Hardens the current thread: refuses the forbidden built-ins through the module hooks and through
102
+ * `process.getBuiltinModule`, which goes round them, removes the globals, empties the environment,
103
+ * and blocks `process.binding`. Call it in a worker thread before importing plugin code; it reaches
104
+ * nothing outside that thread.
90
105
  */
91
106
  export function hardenThread(
92
107
  moduleApi: Pick<typeof import("node:module"), "registerHooks">,
@@ -112,6 +127,19 @@ export function hardenThread(
112
127
  for (const name of ["binding", "_linkedBinding", "dlopen"]) {
113
128
  Object.defineProperty(proc, name, { value: refuse, writable: false, configurable: false });
114
129
  }
130
+ const getBuiltinModule = proc.getBuiltinModule;
131
+ if (typeof getBuiltinModule === "function") {
132
+ Object.defineProperty(proc, "getBuiltinModule", {
133
+ value: (specifier: string): unknown => {
134
+ if (isForbiddenBuiltin(specifier)) {
135
+ throw refusedBuiltin(specifier);
136
+ }
137
+ return getBuiltinModule.call(proc, specifier);
138
+ },
139
+ writable: false,
140
+ configurable: false,
141
+ });
142
+ }
115
143
  const env = proc.env as Record<string, unknown> | undefined;
116
144
  if (env !== undefined) {
117
145
  for (const key of Object.keys(env)) {
@@ -0,0 +1,89 @@
1
+ // Versions and ranges as a manifest spells them (docs/specs/plugins.md, "The manifest"): the
2
+ // range of another plugin's version in `requires`, and the range of the console's version in
3
+ // `console`. Read loosely, as npm spells the common ones, with no library: a plugin declares
4
+ // `>=0.4.3 <0.6`, and the console, the authoring command and the tests compare the same way.
5
+
6
+ export type Triple = readonly [number, number, number];
7
+
8
+ /**
9
+ * A version read loosely: `0.4.3`, `v0.4.3`, `0.6` (as `0.6.0`), `1` (as `1.0.0`), a prerelease or
10
+ * a build after the numbers ignored; nothing for anything else.
11
+ */
12
+ export function parseLooseVersion(text: string): Triple | undefined {
13
+ const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+].*)?$/.exec(text.trim());
14
+ return match === null
15
+ ? undefined
16
+ : [Number(match[1]), Number(match[2] ?? "0"), Number(match[3] ?? "0")];
17
+ }
18
+
19
+ export function compareTriples(a: Triple, b: Triple): number {
20
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
21
+ }
22
+
23
+ /**
24
+ * Whether a version is within a range as npm spells the common ones: `*`, exact, `^`, `~`, `>=`,
25
+ * `>`, `<=`, `<`, several with spaces between (all must hold) and alternatives with `||` between
26
+ * (one must hold). A partial version in the range is read as `.0` filled in: `<0.6` is `<0.6.0`.
27
+ */
28
+ export function satisfiesRange(version: string, range: string): boolean {
29
+ const have = parseLooseVersion(version);
30
+ const trimmed = range.trim();
31
+ if (trimmed === "*" || trimmed === "" || trimmed === "x" || trimmed === "latest") {
32
+ return true;
33
+ }
34
+ if (have === undefined) {
35
+ return false;
36
+ }
37
+ return trimmed.split(/\s*\|\|\s*/).some((alternative) =>
38
+ alternative.split(/\s+/).every((part) => {
39
+ const operator = /^(\^|~|>=|<=|>|<|=)?(.*)$/.exec(part);
40
+ const wanted = parseLooseVersion(operator?.[2] ?? "");
41
+ if (wanted === undefined) {
42
+ return false;
43
+ }
44
+ switch (operator?.[1]) {
45
+ case "^":
46
+ return (
47
+ compareTriples(have, wanted) >= 0 &&
48
+ (wanted[0] === 0 ? have[0] === 0 && have[1] === wanted[1] : have[0] === wanted[0])
49
+ );
50
+ case "~":
51
+ return (
52
+ compareTriples(have, wanted) >= 0 && have[0] === wanted[0] && have[1] === wanted[1]
53
+ );
54
+ case ">=":
55
+ return compareTriples(have, wanted) >= 0;
56
+ case ">":
57
+ return compareTriples(have, wanted) > 0;
58
+ case "<=":
59
+ return compareTriples(have, wanted) <= 0;
60
+ case "<":
61
+ return compareTriples(have, wanted) < 0;
62
+ default:
63
+ return compareTriples(have, wanted) === 0;
64
+ }
65
+ }),
66
+ );
67
+ }
68
+
69
+ /**
70
+ * The version a dependency spells, read loosely: `^0.4.3`, `~0.4.3`, `>=0.4.3` and `0.4.3` are
71
+ * all `0.4.3`; `workspace:*`, `*`, `latest` and a tag name none.
72
+ */
73
+ export function versionOfDependency(spec: string): Triple | undefined {
74
+ return parseLooseVersion(spec.trim().replace(/^(?:\^|~|>=|=)\s*/, ""));
75
+ }
76
+
77
+ export function formatTriple(version: Triple): string {
78
+ return `${version[0]}.${version[1]}.${version[2]}`;
79
+ }
80
+
81
+ /**
82
+ * The range of consoles a plugin written against `version` runs on, as `console-plugin init`
83
+ * writes it: from that version on, within its line, since the plugin API moves with the console's
84
+ * line before 1.0 (`>=0.4.3 <0.5.0`), and within its major from 1.0 (`>=1.2.0 <2.0.0`).
85
+ */
86
+ export function lineRangeFrom(version: Triple): string {
87
+ const below: Triple = version[0] === 0 ? [0, version[1] + 1, 0] : [version[0] + 1, 0, 0];
88
+ return `>=${formatTriple(version)} <${formatTriple(below)}`;
89
+ }
@@ -38,6 +38,7 @@ import { PluginRefusal } from "./refusals.js";
38
38
  export interface TestConsoleOptions {
39
39
  readonly workspace?: PluginConsole["workspace"];
40
40
  readonly people?: Partial<PluginConsole["people"]>;
41
+ readonly teams?: Partial<PluginConsole["teams"]>;
41
42
  readonly projects?: Partial<PluginConsole["projects"]>;
42
43
  readonly tasks?: Partial<PluginConsole["tasks"]>;
43
44
  readonly decisions?: Partial<PluginConsole["decisions"]>;
@@ -45,6 +46,16 @@ export interface TestConsoleOptions {
45
46
  readonly runs?: Partial<PluginConsole["runs"]>;
46
47
  }
47
48
 
49
+ /**
50
+ * Another plugin as a test gives it: its version and its api; and, where `for` is given, what it
51
+ * answers when called for a person. Without `for`, it answers the same for anyone.
52
+ */
53
+ export interface TestOtherPlugin {
54
+ readonly version: string;
55
+ readonly api: OtherPlugin["api"];
56
+ readonly for?: ((personId: string) => TestOtherPlugin) | undefined;
57
+ }
58
+
48
59
  export interface TestContextOptions {
49
60
  readonly plugin?: Partial<PluginInfo>;
50
61
  readonly caller?: PluginCaller;
@@ -56,7 +67,11 @@ export interface TestContextOptions {
56
67
  readonly fetch?: (url: string, init?: PluginFetchInit) => Promise<PluginFetchResponse>;
57
68
  /** The secrets by name; `sign` and `verify` use them, and never hand them over. */
58
69
  readonly secrets?: Readonly<Record<string, string>>;
59
- readonly plugins?: Readonly<Record<string, OtherPlugin>>;
70
+ readonly plugins?: Readonly<Record<string, TestOtherPlugin>>;
71
+ /** The console's public origin, from which `ctx.endpoints.url` is answered; left out, it is `plugin.unavailable`. */
72
+ readonly publicUrl?: string;
73
+ /** The endpoints the plugin defines, by name, which `ctx.endpoints.url` answers for; any name when left out. */
74
+ readonly endpoints?: readonly string[];
60
75
  readonly now?: () => Date;
61
76
  readonly language?: string;
62
77
  }
@@ -226,7 +241,7 @@ function memoryRecords<T extends Record<string, unknown>>(
226
241
  }
227
242
  return found;
228
243
  };
229
- const by = caller.kind === "person" ? caller.person.id : null;
244
+ const by = actingPersonOf(caller)?.id ?? null;
230
245
  const indexed = (field: string) => {
231
246
  if (
232
247
  collection !== undefined &&
@@ -519,15 +534,35 @@ function refusingConsole(given: TestConsoleOptions | undefined): PluginConsole {
519
534
  return {
520
535
  workspace:
521
536
  given?.workspace ?? (async () => forbidden("console.workspace", "console.workspace")),
522
- people: fill("people", ["list", "get"], given?.people),
537
+ people: fill("people", ["list", "get", "granted"], given?.people),
538
+ teams: fill("teams", ["list", "get"], given?.teams),
523
539
  projects: fill("projects", ["list", "get", "members"], given?.projects),
524
540
  tasks: fill("tasks", ["list", "get", "create", "update", "comment"], given?.tasks),
525
- decisions: fill("decisions", ["list", "get", "ask"], given?.decisions),
541
+ decisions: fill("decisions", ["list", "get", "ask", "answer"], given?.decisions),
526
542
  documents: fill("documents", ["list", "read", "write"], given?.documents),
527
543
  runs: fill("runs", ["list", "get"], given?.runs),
528
544
  };
529
545
  }
530
546
 
547
+ /** The person a caller acts as: themselves, or the one a plugin acts for; none for the console or a plugin as itself. */
548
+ function actingPersonOf(caller: PluginCaller) {
549
+ return caller.kind === "person"
550
+ ? caller.person
551
+ : caller.kind === "plugin"
552
+ ? (caller.for ?? undefined)
553
+ : undefined;
554
+ }
555
+
556
+ /** Another plugin as the context hands it over: `for` answers as the test said, or the same as for anyone. */
557
+ function withFor(other: TestOtherPlugin): OtherPlugin {
558
+ const built: OtherPlugin = {
559
+ version: other.version,
560
+ api: other.api,
561
+ for: (personId) => (other.for === undefined ? built : withFor(other.for(personId))),
562
+ };
563
+ return built;
564
+ }
565
+
531
566
  function build(store: Store, options: TestContextOptions, caller: PluginCaller): TestContext {
532
567
  const now = options.now ?? (() => new Date());
533
568
  const collections = new Map(
@@ -538,8 +573,10 @@ function build(store: Store, options: TestContextOptions, caller: PluginCaller):
538
573
  version: "0.0.0",
539
574
  dataVersion: 1,
540
575
  settings: {},
576
+ capabilities: [],
541
577
  board: null,
542
578
  projects: "all",
579
+ grants: [],
543
580
  ...options.plugin,
544
581
  };
545
582
  const secrets = options.secrets ?? {};
@@ -570,6 +607,12 @@ function build(store: Store, options: TestContextOptions, caller: PluginCaller):
570
607
  fetch: options.fetch ?? (async () => forbidden("fetch", "fetch")),
571
608
  secrets: {
572
609
  has: async (name) => name in secrets,
610
+ status: async (name) => ({
611
+ set: name in secrets,
612
+ setAt: name in secrets ? now().toISOString() : null,
613
+ expiresAt: null,
614
+ source: name in secrets ? "administrator" : null,
615
+ }),
573
616
  sign: async (name, payload) => {
574
617
  const secret = secrets[name];
575
618
  if (secret === undefined) {
@@ -590,12 +633,26 @@ function build(store: Store, options: TestContextOptions, caller: PluginCaller):
590
633
  if (other === undefined) {
591
634
  forbidden(`plugins.get(${key})`, `plugins.${key}`);
592
635
  }
593
- return other;
636
+ return withFor(other);
637
+ },
638
+ },
639
+ endpoints: {
640
+ async url(name) {
641
+ if (options.endpoints !== undefined && !options.endpoints.includes(name)) {
642
+ throw new PluginRefusal("plugin.not_found", `the plugin defines no endpoint ${name}`);
643
+ }
644
+ if (options.publicUrl === undefined) {
645
+ throw new PluginRefusal(
646
+ "plugin.unavailable",
647
+ "this console has no public address (PUBLIC_URL) to reach its endpoints at",
648
+ );
649
+ }
650
+ return `${options.publicUrl}/api/v1/plugins/${encodeURIComponent(plugin.key)}/x/${encodeURIComponent(name)}`;
594
651
  },
595
652
  },
596
653
  log: { info: log("info"), warn: log("warn"), error: log("error") },
597
654
  now,
598
- language: options.language ?? (caller.kind === "person" ? caller.person.language : "en"),
655
+ language: options.language ?? actingPersonOf(caller)?.language ?? "en",
599
656
  signal: new AbortController().signal,
600
657
  emitted: store.emitted,
601
658
  logged: store.logged,
package/src/plugin/web.ts CHANGED
@@ -11,11 +11,14 @@ import {
11
11
  type BridgeFile,
12
12
  type BridgeInit,
13
13
  type BridgeMethod,
14
+ type BridgePicture,
14
15
  type BridgeToPage,
15
16
  isBridgePath,
16
17
  isBridgeToFrame,
18
+ isBridgeUrl,
17
19
  MAX_BRIDGE_TITLE_LENGTH,
18
20
  PLUGIN_BRIDGE_VERSION,
21
+ type PluginWidgetSizeName,
19
22
  } from "./bridge.js";
20
23
  import type { PluginRecord, RecordPage, RecordVersion } from "./context.js";
21
24
 
@@ -111,8 +114,21 @@ export interface PluginFrame {
111
114
  type: string,
112
115
  ): Promise<{ readonly handle: string }>;
113
116
  };
117
+ readonly people: {
118
+ /**
119
+ * An address to put in an image, of the person of that id (ADR-0063), with `people:read`; or
120
+ * `null` for a person with no picture to show. Kept for the life of the frame.
121
+ */
122
+ picture(id: string): Promise<string | null>;
123
+ };
114
124
  /** The console's page follows a path of the console's in place; a URL is refused. */
115
125
  navigate(path: string): void;
126
+ /**
127
+ * The console's page opens a web or a mail address in a new tab (ADR-0067), for a link a
128
+ * person pressed: call it from the press itself, since the page opens nothing but right after
129
+ * one. Anything else, a path of the console's included, is refused; `navigate` takes those.
130
+ */
131
+ open(url: string): void;
116
132
  /** The console's page downloads a file of this plugin. */
117
133
  download(id: string): void;
118
134
  setTitle(text: string): void;
@@ -125,12 +141,21 @@ export interface PluginFrame {
125
141
  onTheme(handler: (theme: Readonly<Record<string, string>>) => void): () => void;
126
142
  /** The console navigated within this page's address. */
127
143
  onPath(handler: (path: string) => void): () => void;
144
+ /** The person gave the widget another size (ADR-0059); `context.size` was the first. */
145
+ onSize(handler: (size: PluginWidgetSizeName) => void): () => void;
128
146
  /** Stops listening to the console's page. */
129
147
  close(): void;
130
148
  }
131
149
 
132
150
  const DEFAULT_CONNECT_MS = 10_000;
133
151
 
152
+ /** What a page is told for a person with no picture to show, which is no failure of the page's. */
153
+ const NO_PICTURE: ReadonlySet<string> = new Set([
154
+ "plugin.not_found",
155
+ "picture.refused",
156
+ "picture.unavailable",
157
+ ]);
158
+
134
159
  function globalWindow(): FrameWindow {
135
160
  const candidate = (globalThis as { window?: FrameWindow }).window;
136
161
  if (candidate === undefined) {
@@ -219,7 +244,9 @@ export function connect(options: ConnectOptions = {}): Promise<PluginFrame> {
219
244
  const eventHandlers = new Set<(kind: BridgeEventKind, data: unknown) => void>();
220
245
  const themeHandlers = new Set<(theme: Readonly<Record<string, string>>) => void>();
221
246
  const pathHandlers = new Set<(path: string) => void>();
247
+ const sizeHandlers = new Set<(size: PluginWidgetSizeName) => void>();
222
248
  const subscribed = new Set<BridgeEventKind>();
249
+ const pictures = new Map<string, Promise<string | null>>();
223
250
  let counter = 0;
224
251
  let stopHeight: (() => void) | undefined;
225
252
 
@@ -301,6 +328,26 @@ export function connect(options: ConnectOptions = {}): Promise<PluginFrame> {
301
328
  }>;
302
329
  },
303
330
  },
331
+ people: {
332
+ picture: (id) => {
333
+ const kept = pictures.get(id);
334
+ if (kept !== undefined) {
335
+ return kept;
336
+ }
337
+ const asked = (call("people.picture", { id }) as Promise<BridgePicture>).then(
338
+ ({ type, bytes }) => URL.createObjectURL(new Blob([bytes], { type })),
339
+ (error: unknown) => {
340
+ if (error instanceof PluginBridgeError && NO_PICTURE.has(error.code)) {
341
+ return null;
342
+ }
343
+ pictures.delete(id);
344
+ throw error;
345
+ },
346
+ );
347
+ pictures.set(id, asked);
348
+ return asked;
349
+ },
350
+ },
304
351
  navigate: (path) => {
305
352
  if (!isBridgePath(path)) {
306
353
  throw new PluginBridgeError({
@@ -310,6 +357,16 @@ export function connect(options: ConnectOptions = {}): Promise<PluginFrame> {
310
357
  }
311
358
  post({ v: PLUGIN_BRIDGE_VERSION, t: "navigate", path });
312
359
  },
360
+ open: (url) => {
361
+ if (!isBridgeUrl(url)) {
362
+ throw new PluginBridgeError({
363
+ code: "plugin.forbidden",
364
+ message:
365
+ "open takes a web or a mail address; navigate takes a path of the console's",
366
+ });
367
+ }
368
+ post({ v: PLUGIN_BRIDGE_VERSION, t: "open", url });
369
+ },
313
370
  download: (id) => post({ v: PLUGIN_BRIDGE_VERSION, t: "download", id }),
314
371
  setTitle: (text) => {
315
372
  const title = text.slice(0, MAX_BRIDGE_TITLE_LENGTH).replace(/[\r\n]+/g, " ");
@@ -338,6 +395,10 @@ export function connect(options: ConnectOptions = {}): Promise<PluginFrame> {
338
395
  pathHandlers.add(handler);
339
396
  return () => pathHandlers.delete(handler);
340
397
  },
398
+ onSize: (handler) => {
399
+ sizeHandlers.add(handler);
400
+ return () => sizeHandlers.delete(handler);
401
+ },
341
402
  close: () => {
342
403
  win.removeEventListener("message", listener);
343
404
  stopHeight?.();
@@ -393,6 +454,11 @@ export function connect(options: ConnectOptions = {}): Promise<PluginFrame> {
393
454
  handler(message.path);
394
455
  }
395
456
  return;
457
+ case "size":
458
+ for (const handler of sizeHandlers) {
459
+ handler(message.size);
460
+ }
461
+ return;
396
462
  default:
397
463
  return;
398
464
  }