@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
@@ -10,7 +10,7 @@
10
10
  | plugin | One npm package that adds a feature to a console: commands and queries for agents, pages and widgets for people, records, jobs, hooks and skills; identified by its `key` |
11
11
  | manifest | `console-plugin.json` at the package root: what the plugin is and asks, as data read without running anything |
12
12
  | host | The console's child process that runs plugin code, one per `api` and `worker` role, with a thread per plugin |
13
- | capability | Something a plugin asks of the console in its manifest and an administrator consents to when enabling it; the only way plugin code reaches the console |
13
+ | capability | Something a plugin asks of the console in its manifest, required or optional, and an administrator consents to when enabling it; the only way plugin code reaches the console |
14
14
  | role | A name a plugin gives to what a person may do with it, granted by an administrator |
15
15
  | board project | The project a plugin's decisions, tasks and documents go to, made or chosen when it is enabled |
16
16
  | record, collection | A plugin's data, kept by the console: a collection is declared with a schema, a record is one document in it |
@@ -46,19 +46,23 @@
46
46
  | `name`, `description` | `{ [language]: string }` | `en` required; a name up to 40 characters, a description up to 200 |
47
47
  | `agentNote` | `{ [language]: string }` | one sentence for agents, up to 200 characters: what the plugin is for and when to use it; printed with the rules |
48
48
  | `pluginApi` | integer | the major of the plugin API the plugin was written against; `1` |
49
+ | `console` | range | the consoles the plugin runs on, a semver range of the console's version as `requires` spells one (`>=0.4.3 <0.6`; a partial version is read with zeros filled in); left out, any console that serves `pluginApi`. A console outside it fails the plugin at discovery, saying the range and its own version, and answers the range on `GET /api/v1/plugins`; `console-plugin init` writes the line of the version the plugin is written against |
49
50
  | `server` | path | the server module, relative to the package root |
50
51
  | `web` | path | the directory of the web bundle, with `index.js`; required where `pages` or `widgets` are declared |
51
52
  | `skills` | path | the directory of the skills; `SKILLCDN.md` is then at the package root |
52
53
  | `migrations` | path | the directory of SQL migrations; with `storage:sql` only |
53
- | `capabilities` | string[] | from the catalogue below; `outbound:<host>` and `plugins:<key>` carry their parameter |
54
- | `roles` | `{ [role]: { name, description } }` | role keys as `key`; up to 10; names and descriptions per language |
54
+ | `capabilities` | string[] | what the plugin requires, from the catalogue below; `outbound:<host>` and `plugins:<key>` carry their parameter, `outbound:webrtc` is a word of its own; `plugins:<key>` never names the plugin itself, here or among the optional ones |
55
+ | `optionalCapabilities` | string[] | what it can go without ([ADR-0060](../adr/0060-what-a-plugin-asks-is-required-or-optional-and-the-optional-are-the-administrators-to-choose.md)): the same catalogue, never `storage:sql`, each named once across the two lists, at most 50 together; an administrator turns each on when enabling the plugin or later, off until then |
56
+ | `roles` | `{ [role]: { name, description, follows? } }` | role keys as `key`; up to 10; names and descriptions per language; `follows: "reports"` makes the role one held by what a person is in the directory, whoever someone active reports to, granted by nobody ([ADR-0068](../adr/0068-a-decision-reaches-whom-it-is-addressed-to-wherever-it-is-and-a-plugins-reaches-the-managers-above-them.md)) |
55
57
  | `defaultRole` | string | a role every member of the workspace holds without a grant; optional |
56
58
  | `requires` | `{ [key]: range }` | other plugins this one needs, by a semver range of their version; they are enabled first and cannot be disabled while this one is |
59
+ | `grants` | `{ [key]: { roles, why } }` | the roles of other plugins the plugin asks to hold as itself ([ADR-0072](../adr/0072-a-plugin-is-a-principal-it-holds-roles-granted-to-it-calls-as-itself-and-acts-for-a-person-within-what-both-may-do.md)): up to 10 plugins, each with `why`, localized, for the administrator who grants them when enabling or on the plugin's page; never of itself |
57
60
  | `board` | `{ needs, defaults? }` | `needs` is `required`, `optional` or `none`; `defaults` the `key`, `name` and `description` per language of the project an administrator may make for it |
58
- | `settings` | Setting[] | up to 50, each `{ key, type, name, description?, required?, default?, min?, max?, options?, of? }`; `key` a lowercase letter followed by letters and digits (`daysPerYear`), as code and `--set` name it; types `text`, `markdown`, `number`, `boolean`, `enum` (with `options`, each `{ value, name }`), `day`, `person`, `project`, `secret`, `list` (with `of`, any type but `secret` and `list`); `min` and `max` bound a number, the length of a text or the items of a list; `default` fits the type, and `person`, `project` and `secret` have none |
61
+ | `settings` | Setting[] | up to 50, each `{ key, type, name, description?, required?, default?, min?, max?, options?, of? }`; `key` a lowercase letter followed by letters and digits (`daysPerYear`), as code and `--set` name it; types `text`, `markdown`, `number`, `boolean`, `enum` (with `options`, each `{ value, name }`), `day`, `time` (`HH:MM`, read in the workspace's time zone), `person`, `project`, `secret`, `list` (with `of`, any type but `secret` and `list`); `min` and `max` bound a number, the length of a text or the items of a list; `default` fits the type, and `person`, `project` and `secret` have none; `group` names one of `settingGroups`, under which the form shows the setting; a `secret` may name `hosts`, the hosts it goes into a request to, and is sealed from the answers of, and no other (`slack.com`, `*.example.com`, as `outbound:<host>` names them), and `use: "verify"`, a secret that verifies signatures and signs nothing ([ADR-0070](../adr/0070-a-secret-is-sealed-by-the-console-from-an-answer-and-goes-where-its-setting-says.md)) |
62
+ | `settingGroups` | Group[] | up to 10, each `{ key, name, description? }`, in the order the form shows them; the settings without a `group` come above them |
59
63
  | `menu` | `{ group, order?, name? }` | the tab the plugin is under: a group of the console's (`people`, `customers`, `money`, `operations`, `marketing`, `engineering`, `reports`), or one the plugin declares with a `name` per language; `order` within the row, default 100 |
60
64
  | `pages` | Page[] | up to 12, each `{ key, name, level, roles?, wide? }`; `level` is `workspace` or `project`; page keys as `key`, and not `frame`, `web` or `x` |
61
- | `widgets` | Widget[] | up to 4, each `{ key, name, place, size, roles? }`; `place` is `home:top` or `home:bottom`; `size` is `third`, `half` or `full` |
65
+ | `widgets` | Widget[] | up to 4, each `{ key, name, place, sizes, defaultSize?, roles? }`; `place` is `home:top` or `home:bottom`; `sizes` the sizes the widget comes in, each once, among `small` (2×2 cells of the front page's grid), `medium` (4×2), `large` (4×4), `wide` (8×2) and `full` (8×4), at least one of them `small`, `medium` or `large`, since a phone's grid is four cells across ([ADR-0062](../adr/0062-a-widget-comes-in-a-size-a-phone-can-hold.md)), and `defaultSize` the one it starts at, the first of them when left out ([ADR-0061](../adr/0061-the-front-page-is-one-grid-of-tiles-a-person-arranges-by-hand.md)); the one `size` of before, `third`, `half` or `full`, still reads, as `small`, `medium` or `wide` |
62
66
  | `dataVersion` | integer | the version of the plugin's record shapes, from 1; raised with an entry in `upgrades` |
63
67
  | `store` | object | `homepage`, `repository`, `keywords`, `icon`; the license is `package.json`'s |
64
68
 
@@ -147,17 +151,17 @@ export default definePlugin({
147
151
 
148
152
  | Part | Shape | Notes |
149
153
  |---|---|---|
150
- | `collections` | `[defineCollection(name, { schema, indexes?, search?, owner?, project?, read?, readOwn?, history? })]` | a list; `name` as `key`; `schema` is a zod object (any schema that follows Standard Schema), checked in the plugin's thread on every write; `indexes` (up to 8) are the fields `where`, `order` and `aggregate` may name, beside `id`, `owner` and `project`, which always may; `search` the fields searched as words; `owner` an indexed field holding a person's id, for `readOwn`; `project` is `none` (unless said), `optional` or `required`; `read` and `readOwn` name roles; `history` keeps a version per write, `true` unless said otherwise; up to 50 collections. The definition is what `ctx.records(requests)` takes, and types the records from the schema |
154
+ | `collections` | `[defineCollection(name, { schema, indexes?, search?, owner?, project?, read?, readOwn?, history?, retention? })]` | a list; `name` as `key`; `schema` is a zod object (any schema that follows Standard Schema), checked in the plugin's thread on every write; `indexes` (up to 8) are the fields `where`, `order` and `aggregate` may name, beside `id`, `owner` and `project`, which always may; `search` the fields searched as words; `owner` an indexed field holding a person's id, for `readOwn`; `project` is `none` (unless said), `optional` or `required`; `read` and `readOwn` name roles; `history` keeps a version per write, `true` unless said otherwise; `retention: { days, by? }` forgets a record `days` (1 to 3650) after the moment `by` names, `createdAt` unless said, `updatedAt`, or an indexed field holding a day or an instant as ISO 8601 text, which with `history: false` makes a light collection for logs and deliveries; up to 50 collections. The definition is what `ctx.records(requests)` takes, and types the records from the schema |
151
155
  | `commands` | `defineCommand({ about, args, roles?, run })` | `about` one line; `args` named definers, positional where `positional: true`, in declaration order; `run(ctx, args)` returns `{ text, data?, waiting? }`: `text` is what the command prints, `data` what `--json` prints, `waiting` the id of a decision the caller waits for; up to 100 commands and queries together, named as `key` and not as a subcommand of the command (`help`, `records`, `skills`, `skill`, `enable`, `disable`, `settings`, `secret`, `grant`, `revoke`) |
152
156
  | `queries` | `defineQuery({ about, args, roles?, run })` | reads only; `run` returns JSON up to 4 MiB, or `{ file: { name, type, bytes } }` for an export |
153
157
  | `endpoints` | `defineEndpoint({ auth, roles?, secret?, methods?, run })` | with the `endpoints` capability; `auth` is `person` (a session or a token, with `roles`) or `webhook` (nobody; `secret` names the setting the plugin verifies the signature with); `run(ctx, request)`, the request `{ method, path, query, headers, body }` with the headers `content-type` and those the definition names and the body text up to 1 MiB, returns `{ status, headers?, body? }`, the headers among `content-type`, `location` and `cache-control`, the body a string or JSON up to 4 MiB |
154
- | `on` | `{ "console:<kind>": handler, "plugin:<key>:<name>": handler }` | hooks; `console:` kinds need `events:console`, `plugin:` ones `events:plugins`; `handler(ctx, event)`; idempotent, since delivery is at least once |
158
+ | `on` | `{ "console:<kind>": handler, "plugin:<key>:<name>": handler }` | hooks; `console:` kinds need `events:console`, `plugin:` ones `events:plugins`; `handler(ctx, event)`, the event `{ id, attempt, source, kind, at, plugin, project, data }`; idempotent, since delivery is at least once: `id` names the event once across both streams (`console:<number>`, `plugin:<number>`) and `attempt` which delivery this is, so that a handler that must not act twice keeps the ids it acted on and passes over one it has seen |
155
159
  | `jobs` | `{ [name]: { about, every, at?, run } }` | `every` an ISO 8601 duration of five minutes or more; `at` a time of day (`HH:MM`) in the workspace's time zone, for a daily or longer `every`; one run of a job at a time |
156
160
  | `upgrades` | `[{ to, about, run }]` | run in order by the `migrate` role from the installed `dataVersion` to the manifest's |
157
161
  | `api` | `{ [name]: { about, input, run } }` | what other plugins may call with `plugins:<key>`; `input` a zod schema; `run(ctx, input)` with the caller passed through |
158
162
  | `onEnable`, `onDisable` | `(ctx) => Promise<void>` | optional; run when an administrator enables or disables |
159
163
 
160
- The argument definers: `arg.text({ max?, optional?, secret? })`, `arg.markdown()`, `arg.day()`, `arg.number({ min?, max?, integer? })`, `arg.boolean()`, `arg.enum([...])`, `arg.person()` (a login or `me`, resolved by the console to a person the caller may see, handed to `run` as `{ id, login, name }`), `arg.project()` (a key the caller may see, handed as `{ id, key, name }`), `arg.record("<collection>")` (an id of that collection), `arg.list(inner, { max? })` (a flag repeated, or commas between), `arg.file()` (what `--file <path>` or `-` carried; the command uploads it first and hands a handle). Every definer takes `about` (one line, shown in help), `optional` and `positional`; a positional argument comes before the flags, in declaration order, and no required one follows an optional one. Each yields a plain description, and `run`'s `args` is typed from them: optional ones may be `undefined`. An argument marked `secret` is hidden in the event the command leaves. An argument is not named `json`, `url`, `account`, `project`, `file` or `run`, the flags the command keeps for itself (`RESERVED_ARG_NAMES`). A plugin refuses in its own words by throwing `PluginError(code, message)`, which the console answers as `plugin.<key>.<code>`; a definition the console cannot hold is refused when the module loads, as a `PluginDefinitionError` naming every problem.
164
+ The argument definers: `arg.text({ max?, optional?, secret? })`, `arg.markdown()`, `arg.day()` (`YYYY-MM-DD`, a day the calendar has: February 30 is refused), `arg.number({ min?, max?, integer? })`, `arg.boolean()`, `arg.enum([...])`, `arg.person()` (a login or `me`, resolved by the console to a person the caller may see, handed to `run` as `{ id, login, name }`), `arg.project()` (a key the caller may see, handed as `{ id, key, name }`), `arg.record("<collection>")` (an id of that collection), `arg.list(inner, { max? })` (a flag repeated, or commas between), `arg.file()` (what `--file <path>` or `-` carried; the command uploads it first and hands a handle, and help prints the argument as `--file <path>`, since the flag is the command's and not the argument's name). Every definer takes `about` (one line, shown in help), `optional` and `positional`; a positional argument comes before the flags, in declaration order, and no required one follows an optional one. Each yields a plain description, and `run`'s `args` is typed from them: optional ones may be `undefined`. An argument marked `secret` is hidden in the event the command leaves. An argument is not named `json`, `url`, `account`, `project`, `file` or `run`, the flags the command keeps for itself (`RESERVED_ARG_NAMES`). A plugin refuses in its own words by throwing `PluginError(code, message)`, which the console answers as `plugin.<key>.<code>`; a definition the console cannot hold is refused when the module loads, as a `PluginDefinitionError` naming every problem.
161
165
 
162
166
  ## The context
163
167
 
@@ -165,27 +169,29 @@ Everything a plugin does to the console is a call on `ctx`, which is a message t
165
169
 
166
170
  | Member | What it is | Needs |
167
171
  |---|---|---|
168
- | `ctx.plugin` | `{ key, version, dataVersion, settings, board, projects }`: the settings without the secrets; `board` the board project's `{ id, key }` or `null`; `projects` `"all"` or the keys of the projects the plugin is on | nothing |
169
- | `ctx.caller` | `{ kind: "person" \| "console", person?, agent?, roles, run?, project? }`: in a command, a query or a person's endpoint, the person (`id`, `login`, `name`, `language`, `admin`), the agent's name where a token acts, their roles in this plugin, their open run in the board project if any, and the project the call is on; in a job, a hook, an upgrade or a webhook, `console`, with every role | nothing |
172
+ | `ctx.plugin` | `{ key, version, dataVersion, settings, board, projects, grants }`: the settings without the secrets; `board` the board project's `{ id, key }` or `null`; `projects` `"all"` or the keys of the projects the plugin is on; `grants` the roles of other plugins granted to this one, each `{ plugin, role }` ([ADR-0072](../adr/0072-a-plugin-is-a-principal-it-holds-roles-granted-to-it-calls-as-itself-and-acts-for-a-person-within-what-both-may-do.md)) | nothing |
173
+ | `ctx.caller` | `{ kind: "person" \| "console" \| "plugin", person?, agent?, plugin?, for?, roles, run?, project? }`: in a command, a query or a person's endpoint, the person (`id`, `login`, `name`, `language`, `admin`), the agent's name where a token acts, their roles in this plugin, their open run in the board project if any, and the project the call is on; in a job, a hook, an upgrade or a webhook, `console`, with every role; in an `api` call another plugin made from its own job, hook or webhook, `plugin`, with that plugin's key and the roles granted to it here, and, where it acts for a person, `for` the person (`id`, `login`, `name`, `language`, `admin: false`) and the roles the plugin and the person both hold ([ADR-0072](../adr/0072-a-plugin-is-a-principal-it-holds-roles-granted-to-it-calls-as-itself-and-acts-for-a-person-within-what-both-may-do.md)); a plugin that knows only the first two kinds refuses the third, which fails safe | nothing |
170
174
  | `ctx.records(collection)` | the records of a collection, by its definition (typed from the schema) or by name: `create(data, { project?, owner? })`, `get(id)` (`null` when there is none), `update(id, patch, { baseVersion? })`, `archive(id)`, `restore(id)`, `list({ where?, order?, limit?, after?, project?, archived? })`, `search(words, { where?, limit?, project? })`, `aggregate({ where?, groupBy?, count?, sum?, min?, max? })`, `versions(id)`, `link(id, target)`, `unlink(id, target)`, `links(id)`, and `files(id)` with `attach(handle, { name?, type? })`, `list()` and `remove(fileId)`; `order` is `{ field, descending? }`; a refusal is thrown as a `PluginRefusal` with the console's code | nothing; `files` for the files |
171
175
  | `ctx.sql` | `query(text, params)` and `transaction(async (tx) => ...)`, on the plugin's own role | `storage:sql` |
172
- | `ctx.console.workspace()` | the workspace's name, languages, time zone and categories | nothing |
173
- | `ctx.console.people` | `list({ q? })` and `get(id)`: the people the caller may see; everyone for a `console` caller, or with `people:read:all` | `people:read` |
174
- | `ctx.console.projects` | `list()`, `get(key)`, `members(key)`: the projects the caller may see; all with `projects:read:all` | `projects:read` |
176
+ | `ctx.console.workspace()` | the workspace's name, languages, time zone (`timeZone`, `UTC` where none is set, and `timeZoneSet`, whether an administrator set one, so that a plugin that reads a time of day says so instead of taking UTC for a choice), the day its week begins on (`weekStart`: `auto`, `monday` or `sunday`), categories, and `url`, the console's public origin (`PUBLIC_URL`) for a link to the console in what the plugin writes elsewhere, `null` where the deployment names none | nothing |
177
+ | `ctx.console.people` | `list({ q? })` and `get(id)`: the people the caller may see, each with `managerId`, whom they report to in the directory or `null`, for a plugin to take as its default where it needs someone's manager, `language`, what they read in or `null` until the pages have seen them, and `teams`, the keys of the teams they are in ([ADR-0073](../adr/0073-a-team-is-a-name-and-its-members-a-unit-to-call-people-by-and-to-grant-a-plugins-role-to.md), [ADR-0074](../adr/0074-a-team-is-called-by-a-key-that-never-changes-one-name-among-the-projects-keys.md)); everyone for a `console` caller, or with `people:read:all`; their work `email` with `people:read:email` | `people:read` |
178
+ | `ctx.console.teams` | `list()` and `get(key)`: the teams of the workspace ([ADR-0073](../adr/0073-a-team-is-a-name-and-its-members-a-unit-to-call-people-by-and-to-grant-a-plugins-role-to.md)), each `{ id, key, name, memberIds, createdAt }`, the members among the people the caller may see; a team is called by its `key`, which never changes | `people:read` |
179
+ | `ctx.console.projects` | `list()`, `get(key)`, `members(key)`: the projects the caller may see; all with `projects:read:all`, but in a call for a person, the person's ([ADR-0072](../adr/0072-a-plugin-is-a-principal-it-holds-roles-granted-to-it-calls-as-itself-and-acts-for-a-person-within-what-both-may-do.md)) | `projects:read` |
175
180
  | `ctx.console.tasks` | `list(project, filters)`, `get(project, number)`, `create(project, task)`, `update(project, number, patch)`, `comment(project, number, body)`: reading in the plugin's projects, creating there, changing and commenting its own | `board:read`; `board:tasks` |
176
- | `ctx.console.decisions` | `list(project)`, `get(id)`, `ask(project, { question, body?, options, deciderId?, taskId?, about })`: `options` each `{ label, key? }`, the `key` spelled as a plugin's key is and given once, answered back as `optionKey` on `console:decision.answered`; `about` names the record the decision is for; there is no `answer` | `board:read`; `board:decisions` |
181
+ | `ctx.console.decisions` | `list(project)`, `get(id)`, `ask(project, { question, body?, options, deciderId?, deciderIds?, taskId?, about })`: `options` each `{ label, key? }`, the `key` spelled as a plugin's key is and given once, answered back as `optionKey` on `console:decision.answered`; `about` names the record the decision is for; `answer(id, { optionKey \| option, note? }, { for: personId })` answers for a person who answered elsewhere a decision on their own plate, addressed to them or a plugin's addressed to someone they manage, up the reporting line ([ADR-0068](../adr/0068-a-decision-reaches-whom-it-is-addressed-to-wherever-it-is-and-a-plugins-reaches-the-managers-above-them.md)), and none they could answer only as a member of its project or as an administrator, `plugin.forbidden` otherwise; as theirs through the plugin ([ADR-0072](../adr/0072-a-plugin-is-a-principal-it-holds-roles-granted-to-it-calls-as-itself-and-acts-for-a-person-within-what-both-may-do.md)); a plugin answers no decision itself | `board:read`; `board:decisions`; `people:act` for `answer` |
177
182
  | `ctx.console.documents` | `list(project, folder)`, `read(project, path)`, `write(project, path, { title, body })` under `plugins/<key>/` | `board:read`; `board:documents` |
178
183
  | `ctx.console.runs` | `list(project)`, `get(id)` | `board:read` |
179
184
  | `ctx.events.emit(kind, data)` | an event of the plugin's own, for its frames and for other plugins' hooks; `kind` as `key`, `data` JSON up to 16 KiB | nothing |
180
- | `ctx.fetch(url, init)` | an outbound request; `init` may carry `secret: { name, as }`, `as` one of `header:<name>`, `query:<name>` and `basic`, and the console puts the secret there | `outbound:<host>` for the URL's host |
181
- | `ctx.secrets` | `has(name)`, `sign(name, payload, algorithm)`, `verify(name, payload, signature, algorithm)` with `hmac-sha256`; never the value | nothing |
182
- | `ctx.plugins.get(key)` | `{ version, api: { [name]: (input) => Promise<unknown> } }` of another plugin, the caller passed through | `plugins:<key>` |
185
+ | `ctx.fetch(url, init)` | an outbound request, its `method` `GET` (the default), `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE` or `OPTIONS` and no other (`TRACE`, which has a server send the request back, is `plugin.invalid_args`), answered `{ status, headers, bytes, sealed?, text(), json() }`, a redirect as it came, never followed; `init` may carry `secret: { name, as }` or `secrets: [...]`, five at most, `as` one of `header:<name>`, `bearer` (`Authorization: Bearer`), `query:<name>`, `basic` (`user:password`) and `body:<field>` (a field of a JSON object or a form body), and the console puts each secret there, within the `hosts` its setting names and before its expiry, and takes it out of the answer's headers and body in every form it knows (as placed, escaped as JSON, URL-encoded, and for `basic` the base64 of the pair and the password alone), `"[withheld]"` in its place, so that a server that echoes the request, or a redirect that carries its URL, hands the plugin none; and `seal: { <setting>: "$.path" \| { path, expiresIn } }`, five at most, values of the answer's JSON the console seals as the plugin's secret settings before the plugin reads the answer, which says `"[sealed]"` in their place, `expiresIn` the seconds they last or a path to them, a setting with `hosts` sealed from their answers alone (`plugin.forbidden` before the request goes out otherwise). Sealing fails closed, and `sealed` says what became of each value named, by its setting: `sealed`; `absent`, nothing at the path (no such key, `null` or an empty string), nothing taken out, so that a service's error stays readable; `not_a_string`, `too_long` (over 4,096 characters) or `echoed` (holding a secret the request carried from another setting), never sealed, `"[withheld]"` in its place; `not_json`, an answer that is no JSON object or array, whose body is withheld whole, the plugin reading its status and headers alone (a token endpoint that answers a form unless asked otherwise is asked with `accept: application/json`). A value sealed or withheld is taken out of the rest of the answer too ([ADR-0070](../adr/0070-a-secret-is-sealed-by-the-console-from-an-answer-and-goes-where-its-setting-says.md)) | `outbound:<host>` for the URL's host |
186
+ | `ctx.secrets` | `has(name)`, whether it is set and not past its expiry; `status(name)`, `{ set, setAt, expiresAt, source }`, `source` being `administrator` or the host of the answer it was sealed from; `sign(name, payload, algorithm)` and `verify(name, payload, signature, algorithm)` with `hmac-sha256`; a secret declared `use: "verify"` signs nothing and goes into no request; never the value | nothing |
187
+ | `ctx.plugins.get(key)` | `{ version, api: { [name]: (input) => Promise<unknown> }, for(personId) }` of another plugin, never of itself (`plugin.forbidden`): a person's call passed through as the person; the console's own call as this plugin, with the roles granted to it there; `for(personId)` the same api for a person who did something elsewhere, with the roles both hold, in their language ([ADR-0072](../adr/0072-a-plugin-is-a-principal-it-holds-roles-granted-to-it-calls-as-itself-and-acts-for-a-person-within-what-both-may-do.md)) | `plugins:<key>`; `people:act` for `for` |
188
+ | `ctx.endpoints.url(name)` | the full address of an endpoint the plugin defines, under the console's public origin (`<PUBLIC_URL>/api/v1/plugins/<key>/x/<name>`), to register a callback or a webhook with a service elsewhere; `plugin.not_found` for a name it does not define, `plugin.unavailable` where the console has no public address | nothing |
183
189
  | `ctx.log` | `info`, `warn` and `error` lines, written to the console's log with the plugin's key and the caller; up to 100 lines a minute | nothing |
184
- | `ctx.now()`, `ctx.language`, `ctx.signal` | the time; the caller's language, `en` for `console`; the `AbortSignal` that fires at the bound | nothing |
190
+ | `ctx.now()`, `ctx.language`, `ctx.signal` | the time; the caller's language, `en` for `console` and for a person the pages have not seen; the `AbortSignal` that fires at the bound | nothing |
185
191
 
186
192
  What `ctx` never has, whatever the manifest asks: the environment, a connection of the console's to the database, a session or a token, a secret's value, another plugin's records or secrets, a way to answer a decision, to change a person, a role, a membership or a setting of the workspace, or to make a request a person could not make themselves.
187
193
 
188
- A read of the console in a person's call is the person's: `people.list` is the people they may see, `projects.list` the projects they may see, a task they may not see is not found. A `console` caller (a job, a hook) sees what the plugin's capabilities open: `people:read` alone shows a job nobody, `people:read:all` everyone. A plugin that wants a person to see more than they may must not; the capability for the whole directory is declared and consented to as such.
194
+ A read of the console in a person's call is the person's: `people.list` is the people they may see, `projects.list` the projects they may see, a task they may not see is not found. In a call a plugin makes for a person, it is the person's as a member's, whatever they administer: acting for a person never carries their administration, nor the view of the administrator who enabled the plugin ([ADR-0072](../adr/0072-a-plugin-is-a-principal-it-holds-roles-granted-to-it-calls-as-itself-and-acts-for-a-person-within-what-both-may-do.md)). A `console` caller (a job, a hook) sees what the plugin's capabilities open: `people:read` alone shows a job nobody, `people:read:all` everyone; so does a plugin another calls as itself. A plugin that wants a person to see more than they may must not; the capability for the whole directory is declared and consented to as such. An id plugin code names that is none, a decision's, a run's, a person's or a goal's, is refused before the database is asked: as nothing found where a call answers `null` or `plugin.not_found` for one missing, else as `plugin.invalid_args`.
189
195
 
190
196
  ## Capabilities
191
197
 
@@ -195,42 +201,46 @@ Declared in the manifest, consented to by an administrator when enabling, enforc
195
201
  |---|---|---|
196
202
  | (none) | its own records, versions, links and events; its settings; its log | its own data, spoiled; the versions restore it |
197
203
  | `files` | files on its records, within `PLUGIN_FILE_BYTES_AT_MOST` | storage filled to the bound |
198
- | `people:read` | the people the caller may see, as the person | the names and logins its role holders may see anyway |
199
- | `people:read:all` | everyone, for `console` callers too; and the workspace's people events (`person.joined`, `person.deactivated`, `person.reactivated`, `person.role_changed`, `person.listed`, `person.unlisted`) to its hooks, with their content | the directory, and when people come and go |
204
+ | `people:read` | the people the caller may see, as the person, and among them who was granted a role of the plugin (`ctx.console.people.granted(role)`, an administrator listed only where granted it) and, for a frame, their pictures | the names, logins and pictures its role holders may see anyway |
205
+ | `people:read:all` | everyone, for `console` callers too; and the workspace's people events (`person.joined`, `person.deactivated`, `person.reactivated`, `person.role_changed`, `person.manager_changed`, `person.email_changed`, `person.listed`, `person.unlisted`, and the teams' `team.created`, `team.renamed`, `team.removed`, `team.member_added`, `team.member_removed`) to its hooks, with their content | the directory, and when people come and go |
206
+ | `people:read:email` | the work email of the people it may read (`email` on `ctx.console.people.list` and `get`, `null` until known; left out without it): the address a person signs in with at Google, else what an administrator wrote, by which the plugin matches them to an account elsewhere; `person.email_changed` says that one changed, never what it is | the addresses of the people its role holders may see |
207
+ | `people:act` | acting for a person who did something elsewhere ([ADR-0072](../adr/0072-a-plugin-is-a-principal-it-holds-roles-granted-to-it-calls-as-itself-and-acts-for-a-person-within-what-both-may-do.md)): `ctx.plugins.get(key).for(personId)` and `ctx.console.decisions.answer(id, answer, { for })`, within what the person may do as a member, whatever they administer, and what the plugin was granted, both; a decision only where it is on their plate; recorded as the person's through the plugin | what its role holders may do as members, done in their name from elsewhere; the decisions on their plates answered |
200
208
  | `projects:read` | the projects and members the caller may see | what the person sees |
201
209
  | `projects:read:all` | every project and its members | the projects and who is in them |
202
210
  | `board:read` | the tasks, decisions, runs, documents and events of its board projects, and of the project a call is on | the content of those boards, to its role holders |
203
211
  | `board:tasks` | tasks created in its projects; its own changed, closed, commented | tasks that mislead; each marked as the plugin's, put away by anyone |
204
- | `board:decisions` | decisions raised in its projects; the answers to its own read | questions that mislead; never an answer |
212
+ | `board:decisions` | decisions raised in its projects; the answers to its own read | questions that mislead; never an answer of its own (an answer for a person is `people:act`) |
205
213
  | `board:documents` | pages written under `plugins/<key>/` in its projects | pages that mislead, in its folder |
206
214
  | `events:console` | hooks on the console's events of its board projects: the kind, the ids and the time, the content with `board:read` | when things happen on those boards |
207
215
  | `events:plugins` | hooks on the events other plugins emit | what they publish |
208
216
  | `jobs` | runs on a schedule | time, within the bound |
209
217
  | `outbound:<host>` | `ctx.fetch` to that host, a public address, over https | what it may read, sent to that host; a cost there |
218
+ | `outbound:webrtc` | WebRTC on its pages: connections from each person's browser to any address ([ADR-0066](../adr/0066-webrtc-is-a-way-out-a-plugin-asks-for-and-how-a-frame-is-held-stays-in-one-place.md)) | what its pages are shown, sent anywhere from the person's browser; their address |
210
219
  | `endpoints` | inbound requests under its path, a person's or a webhook's | input it accepts without verifying; the rate limit holds |
211
- | `plugins:<key>` | that plugin's `api`, as the caller | what that API gives the caller |
220
+ | `plugins:<key>` | that plugin's `api`, as the caller: a person passed through, or this plugin as itself with the roles granted to it ([ADR-0072](../adr/0072-a-plugin-is-a-principal-it-holds-roles-granted-to-it-calls-as-itself-and-acts-for-a-person-within-what-both-may-do.md)); and that plugin's `plugin.enabled`, `plugin.disabled` and `plugin.upgraded` to its hooks | what that API gives the caller, or this plugin's own roles in it |
212
221
  | `storage:sql` | a schema and a login role of its own | statements up to the timeout, within its schema |
213
222
 
214
- `outbound:<host>` names a host exactly or as `*.example.com`; the console resolves the host before connecting and refuses its own networks, as it does for a picture ([ADR-0048](../adr/0048-a-picture-from-elsewhere-is-read-through-the-console-from-a-public-address-and-kept-a-while.md)). A new version of a plugin that declares a capability its install was not consented to waits, disabled, as `needs consent`, until an administrator consents again.
223
+ `outbound:<host>` names a host exactly or as `*.example.com`; the console resolves the host before connecting and refuses its own networks, as it does for a picture ([ADR-0048](../adr/0048-a-picture-from-elsewhere-is-read-through-the-console-from-a-public-address-and-kept-a-while.md)). A new version of a plugin that requires a capability its install was not consented to waits, disabled, as `needs consent`, until an administrator consents again; a new optional capability waits for nothing, and stays off until one is turned on. `ctx.plugin.capabilities` says what the plugin holds, so that it offers what it can do; a page asks its own server.
215
224
 
216
225
  ## Roles and grants
217
226
 
218
227
  - A plugin declares roles; what each means is the plugin's to say in its description and to enforce where it reads, since the console knows a role by its name alone.
219
- - A grant is an administrator's: a role to a person, to the members of a project (a grant that follows the membership), or to everyone. `defaultRole` is everyone's without a grant. An administrator holds every role of every plugin.
228
+ - A grant is an administrator's: a role to a person, to the members of a project (a grant that follows the membership), to a team ([ADR-0073](../adr/0073-a-team-is-a-name-and-its-members-a-unit-to-call-people-by-and-to-grant-a-plugins-role-to.md)), whose members hold it as the membership stands, to everyone, or to another plugin, which holds it as itself ([ADR-0072](../adr/0072-a-plugin-is-a-principal-it-holds-roles-granted-to-it-calls-as-itself-and-acts-for-a-person-within-what-both-may-do.md)). `defaultRole` is everyone's without a grant. A role with `follows: "reports"` is held by whoever someone active reports to, read from the directory on every call, so that it comes and goes with the reporting line ([ADR-0068](../adr/0068-a-decision-reaches-whom-it-is-addressed-to-wherever-it-is-and-a-plugins-reaches-the-managers-above-them.md)); `ctx.console.people.granted(role)` lists them, with whoever was granted the role besides, and the settings page says so. An administrator holds every role of every plugin.
220
229
  - A command, query, page, widget or endpoint names the roles that may use it (any of them); without `roles`, every member may. A collection names the roles that read it, and those that read their own records in it.
221
230
  - A token acts with its person's roles; nothing is granted to an agent ([ADR-0021](../adr/0021-a-token-configures-as-its-person-does.md)).
231
+ - A plugin asks roles of other plugins in its manifest (`grants: { <key>: { roles, why } }`, [ADR-0072](../adr/0072-a-plugin-is-a-principal-it-holds-roles-granted-to-it-calls-as-itself-and-acts-for-a-person-within-what-both-may-do.md)): the enable form offers to grant each whose plugin is enabled, the plugin's page lists them with how they stand, `GET /api/v1/plugins/<key>` answers them as `asks` to an administrator, a role the plugin holds as the callee's `defaultRole` standing as granted, and the plugin reads what it holds as `ctx.plugin.grants`. A plugin holds the roles granted to it by key and the callee's `defaultRole`, as a member does; never an administrator's standing, never a grant to everyone. What it does for a person is the person's act, within what both may do.
222
232
  - A person without a role in a plugin sees none of its tabs or widgets, and its pages and commands are `plugin.forbidden`; the plugin itself is listed, so that the person knows what to ask for.
223
233
 
224
234
  ## Settings
225
235
 
226
- The form on `/settings/plugins/<key>` and `console plugin <key> settings --set` come from the manifest's `settings`. A `secret` is sealed with the deployment's secret (`AUTH_SECRET`, with the purpose `plugin-secret`), never returned by any route or shown in any form (only whether and when it was set), and used by the console where the plugin names it: `ctx.fetch`'s `secret`, `ctx.secrets.sign` and `verify`, a webhook's `secret`. A deployment without `AUTH_SECRET` refuses settings of type `secret`. `person` and `project` settings hold ids and are shown as names, chosen on the page among the workspace's people and projects; a person who left or a project put away makes the setting invalid, which the pages show. Settings are the workspace's; a plugin that needs a value per project keeps a record linked to the project.
236
+ The form on `/settings/plugins/<key>` and `console plugin <key> settings --set` come from the manifest's `settings`. A `secret` is sealed with the deployment's secret (`AUTH_SECRET`, with the purpose `plugin-secret`), never returned by any route or shown in any form (only whether and when it was set), and used by the console where the plugin names it: `ctx.fetch`'s `secret` and `secrets`, `ctx.secrets.sign` and `verify`, a webhook's `secret`. A secret is set by an administrator, or sealed by the console from an answer the plugin received (`ctx.fetch`'s `seal`, [ADR-0070](../adr/0070-a-secret-is-sealed-by-the-console-from-an-answer-and-goes-where-its-setting-says.md)); the page and `console plugin <key> settings` say which, with the host it came from and when it expires, and a secret past its expiry stands unset until it is set or sealed again. A deployment without `AUTH_SECRET` refuses settings of type `secret`. `person` and `project` settings hold ids and are shown as names, chosen on the page among the workspace's people and projects; a person who left or a project put away makes the setting invalid, which the pages show. A `time` is `HH:MM`, read in the workspace's time zone, which the form says beside it; where the workspace has none, a plugin with a `time` setting or a job at a time of day runs on UTC, and the plugin's settings page and `console plugin <key> enable` tell the administrator so (`needsTimeZone` on `GET /api/v1/plugins/<key>`), with the way to set one. A job's next time follows the zone: set or changed, the next run is computed again at the job's time of day in the new zone. A `list` has a control for its kind of item: a list of an `enum` is a pill per choice (a column of boxes past eight), a list of `person` or `project` the chips of those chosen with one choice to add another, a list of anything else the items as chips with a field that adds one, `min` and `max` bounding how many; `--set` takes a list with commas between, as before. The form is divided into the `settingGroups` the manifest declares, a titled block each in the manifest's order, the settings without a group above them. Settings are the workspace's; a plugin that needs a value per project keeps a record linked to the project.
227
237
 
228
238
  ## Records
229
239
 
230
240
  The console keeps a plugin's records in `plugin_records` (id UUIDv7, plugin, collection, project, owner, data, version, created and updated by whom and through what, archived), `plugin_record_versions`, `plugin_record_files` (a file in the blob store, by content hash) and `plugin_links` (a record to a task, a decision, a document, a project, a run, or a record of any plugin; and the board's items a plugin made, with no record).
231
241
 
232
242
  - `where` is `{ field: value | { in: [...] } | { gte, gt, lte, lt } | { exists } }` on `indexes` fields, several combined with and; `order` one `indexes` field, ascending or descending, the id as the tie; pages by `limit` (up to 200) and `after` (the cursor the last page returned). `search` is the database's text search over the `search` fields, as documents are searched. `aggregate` groups by up to two `indexes` fields and counts, sums, takes the least and the most of numeric fields.
233
- - A write checks the schema, bounds the record to 64 KiB and the collection to the plugin's quota, and keeps the version before it where `history` is on; `update` with `baseVersion` is `plugin.conflict` when the record moved on. `archive` keeps a record out of lists; nothing is deleted but by purge.
243
+ - A write checks the schema, bounds the record to 64 KiB and the collection to the plugin's quota, and keeps the version before it where `history` is on; `update` with `baseVersion` is `plugin.conflict` when the record moved on. `archive` keeps a record out of lists; nothing is deleted but by purge, and by a collection's `retention`: every hour the worker removes the records past it, archived or not, with their versions, links and files, so that a collection of deliveries or log lines never reaches the quota, which counts a record while it is kept. It looks at a hundred records at a time in the order they were made, each step a statement within the statement timeout whatever history the records carry, for a minute an hour at most, the rest left to the next hour; each collection is swept apart, and one that fails is a line in the log while the others are swept all the same. `GET /api/v1/plugins/<key>` answers what the plugin forgets and when (`keeps`), and the settings page says it, for the administrator who turns it on.
234
244
  - Who reads a record through the console's generic routes and the bridge: a person with a `read` role of the collection, or a `readOwn` role where the record's `owner` is them; and, where the record is linked to a project, only if they may see the project. Plugin code reads its own records whole; what it shows is its own responsibility, `ctx.caller` saying who asks.
235
245
  - Files: `POST .../files` takes bytes from a person's page or from the command and answers a handle good for an hour; a command attaches the handle to a record, and the console counts the bytes to the plugin's bound. `GET .../files/<id>` is read under the record's rule.
236
246
  - Export: `GET .../export` is JSON Lines, a line per record, version, link, grant and setting, then the list of the files with their hashes; an administrator's.
@@ -243,23 +253,24 @@ With `storage:sql` consented to, enabling creates the schema `plugin_<key>` and
243
253
 
244
254
  - `board.needs` says whether the plugin puts things on a board. Enabling one with `required` or `optional`, the administrator makes a project from `board.defaults` (the key, name and description in the workspace's languages, edited as they like; private; they are its owner) or chooses one that exists; several plugins may bind to one project. The install keeps the project's id, and the project's settings page shows the plugins bound to it.
245
255
  - A plugin with `level: project` pages, or with `project: optional` or `required` collections, is on the projects the administrator names: all, or a list. A plugin on no project has no project pages. Owners do not enable plugins in their projects.
246
- - What a plugin makes on a board carries its name: the console writes the link (`plugin_links`: plugin, kind, id) when `ctx.console.tasks.create`, `decisions.ask` or `documents.write` runs, the event carries `plugin = <key>` beside `actor` and `agent`, and the pages and the feed say "through <plugin>" (`plugin` on a task, a decision and a document as the API answers them). A person's call acts as the person; a `console` caller, a job or a hook, acts as the administrator who enabled the plugin, who owns what it makes, within the plugin's projects alone. A plugin changes, closes, comments or reads the answer of its own items only; it reads every item of its board projects with `board:read`. Its documents go under `plugins/<key>/`; a write elsewhere is `plugin.forbidden`.
247
- - A decision a plugin raises is a decision as any ([ADR-0042](../adr/0042-a-decision-is-addressed-to-a-person-its-raiser-unless-another-is-named.md)): addressed to the person named in `deciderId`, who must be able to work in the project, else to the project's owners; answered by anyone of the project; shown among the person's decisions and in `console work`. The command that raised it returns `waiting` and exits with `3`, and the agent waits as it does for `ask`. The plugin learns of the answer through `on["console:decision.answered"]`, which `about` lets it map to its record, and whose `data` names the chosen option by its label (`option`) and by the key the plugin gave it (`optionKey`), for its own decisions with `board:decisions` alone, so that it reads the answer without comparing labels or asking again; the person's own answer has no key. A plugin never answers.
256
+ - What a plugin makes on a board carries its name: the console writes the link (`plugin_links`: plugin, kind, id) when `ctx.console.tasks.create`, `decisions.ask` or `documents.write` runs, the event carries `plugin = <key>` beside `actor` and `agent`, and the pages and the feed say "through <plugin>" (`plugin` on a task, a decision and a document as the API answers them). A person's call acts as the person; a call a plugin makes for a person acts as the person, a member whatever they administer, and the event names the plugin that acted for them as `via`; a `console` caller, a job or a hook, and a plugin another calls as itself act as the administrator who enabled the plugin, who owns what it makes, within the plugin's projects alone, through no one. A plugin changes, closes, comments or reads the answer of its own items only; it reads every item of its board projects with `board:read`. Its documents go under `plugins/<key>/`; a write elsewhere is `plugin.forbidden`.
257
+ - A decision a plugin raises is a decision as any ([ADR-0042](../adr/0042-a-decision-is-addressed-to-a-person-its-raiser-unless-another-is-named.md)): addressed to the person named in `deciderId`, or to the several named in `deciderIds`, any of whom may answer and the first answer ending it ([ADR-0057](../adr/0057-a-decision-may-be-addressed-to-several-people-and-the-first-answer-ends-it.md)), each an active person of the workspace, in the board project or not ([ADR-0068](../adr/0068-a-decision-reaches-whom-it-is-addressed-to-wherever-it-is-and-a-plugins-reaches-the-managers-above-them.md)), else to the administrator who enabled the plugin; answered by anyone of the project, by those it is addressed to wherever they are, and by whoever manages one of them, up the reporting line; shown among each person's decisions and in `console work`, read and answered at `/decisions/<id>` and `GET /api/v1/decisions/<id>` by a person who may not see the project, which is where a plugin's page sends them (`frame.navigate("/decisions/<id>")`). The command that raised it returns `waiting` and exits with `3`, and the agent waits as it does for `ask`. The plugin learns of the answer through `on["console:decision.answered"]`, which `about` lets it map to its record, and whose `data` names the chosen option by its label (`option`) and by the key the plugin gave it (`optionKey`), for its own decisions with `board:decisions` alone, so that it reads the answer without comparing labels or asking again; the person's own answer has no key. A plugin never answers of itself; with `people:act` it answers for a person a decision on their plate (`ctx.console.decisions.answer`).
258
+ - What a plugin writes for people, a decision's question, body and labels, a task's title and body, a comment's body, is one text, in the language of whoever it is for ([ADR-0065](../adr/0065-a-plugin-writes-one-text-and-a-persons-language-is-kept-from-the-first-time-the-pages-see-them.md)). `ctx.console.people.get(id)` says a person's `language`: the one they chose, else the one the pages first saw them in, `null` until then, where the plugin decides, the first of `ctx.console.workspace().languages` by default. Where several may answer, it chooses one of them. Whoever else reads the text reads it as written, as with what a person writes.
248
259
  - Where a record is linked to a project and the caller may work there, `ask` and `tasks.create` may name that project instead of the board project.
249
260
  - Two plugins on one project do not collide: each edits its own, reads all, writes in its own folder; one refers to another's work by reading, or through the other's `api`.
250
261
 
251
262
  ## Events, hooks and jobs
252
263
 
253
- - New kinds of the console's events: `plugin.enabled`, `plugin.disabled`, `plugin.configured`, `plugin.granted`, `plugin.revoked`, `plugin.upgraded`, `plugin.command`, `plugin.failed`, `plugin.purged`. `events.plugin` names the plugin on every event a plugin caused, its own kinds and the board's. `plugin.command` records the plugin, the command, the arguments (the secret ones as `[hidden]`), the outcome (`ok`, `refused` with its code, `waiting`, `failed`), the duration and the caller's run. They are in the board project's feed where there is one, and always at `GET /api/v1/plugins/<key>/events`.
264
+ - New kinds of the console's events: `plugin.enabled`, `plugin.disabled`, `plugin.configured`, `plugin.granted`, `plugin.revoked`, `plugin.upgraded`, `plugin.command`, `plugin.failed`, `plugin.purged`. `events.plugin` names the plugin on every event a plugin caused, its own kinds and the board's. `plugin.configured` names the fields that changed and, for a secret, its `source`: `administrator`, or the host of the answer the console sealed it from. `plugin.granted` and `plugin.revoked` name a `grantee` where the role went to a plugin. An event a plugin caused for a person names the person as its actor and that plugin as `via`, beside `plugin` ([ADR-0072](../adr/0072-a-plugin-is-a-principal-it-holds-roles-granted-to-it-calls-as-itself-and-acts-for-a-person-within-what-both-may-do.md)). `plugin.command` records the plugin, the command, the arguments (the secret ones as `[hidden]`), the outcome (`ok`, `refused` with its code, `waiting`, `failed`), the duration and the caller's run. They are in the board project's feed where there is one, and always at `GET /api/v1/plugins/<key>/events`.
254
265
  - A plugin's own events (`ctx.events.emit`, the kind named as a `key`, the data JSON up to 16 KiB) go to `plugin_events` (plugin, kind, data, at; 100,000 kept, for ninety days, the janitor sweeping the rest), reach the frames of that plugin that subscribed, read at `GET .../plugin-events?after=&limit=` by its role holders, and the hooks of plugins with `events:plugins` on `plugin:<key>:<kind>`. They are not the board's feed.
255
- - Hooks run in the `worker` role's host. A dispatcher reads the console's events and `plugin_events` from a cursor per plugin (`plugin_cursors`, begun at the end of both streams when the plugin is enabled, so that it is not told of everything that ever happened) under an advisory lock, so that one process dispatches, and delivers each event to the handler that named its kind; a console event is delivered for the plugin's board project, the projects it is on, the items it made and what it caused, and, for a plugin with `people:read:all`, the workspace's people events (`person.*`), with the kind, the ids and the time, and the content and the actor with `board:read`, or with `people:read:all` for a people event, since the directory is its to read. Delivery is at least once and in order: a handler that fails is retried five times with doubling backoff, then the event is skipped with a `plugin.failed` event; a handler has thirty seconds.
256
- - Jobs run in the `worker` role's host, with the `jobs` capability, each under an advisory lock, never two at once; `plugin_jobs` keeps the last run, its outcome and the next time, the first run one period after the scheduler first sees the job, `at` read in the workspace's time zone; a failed job is a `plugin.failed` event, at most one an hour per job, and a line in the log every time. A job has five minutes.
266
+ - Hooks run in the `worker` role's host. A dispatcher reads the console's events and `plugin_events` from a cursor per plugin (`plugin_cursors`, begun at the end of both streams when the plugin is enabled, so that it is not told of everything that ever happened), each plugin under an advisory lock of its own, so that one process dispatches to a plugin at a time and two processes to two plugins at once. The lock is held by a transaction the server lets idle for as long as a pass may take on the plugin, its time, the hook begun last and a minute more, in place of the thirty seconds an idle transaction has otherwise, and it is taken with two keys, which no job's lock of one key can be. A process delivers to a few plugins side by side, a quarter as many as it has database connections, at least one and four at most (one at a time in the `worker` role, whose pool has four), since each holds a connection for its lock and takes more for its queries and its hook's calls on `ctx`: a plugin whose hook waits on a slow service, or waits out its backoff, holds up no plugin delivered to beside it, and the pool keeps room for the rest, the API's requests where the `api` role carries the worker. A plugin that has had twenty seconds of a pass leaves the rest of its events to the next. It delivers each event to the handler that named its kind; a console event is delivered for the plugin's board project, the projects it is on, the items it made and what it caused, and, for a plugin with `people:read:all`, the workspace's people events (`person.*`) and teams' events (`team.*`), with the kind, the ids and the time, and the content and the actor with `board:read`, or with `people:read:all` for a people event, since the directory is its to read. Delivery is at least once and in order: a handler that fails is retried five times with doubling backoff, then the event is skipped with a `plugin.failed` event; a handler has thirty seconds. Every delivery carries the event's `id` and the `attempt`, 1 the first time, so that a hook keeps from acting twice by the id alone, where before it had to make a key of the kind, the record and the time. A hook told to come back later by the service it called, a rate limit or an outage, says so with `retryAfterMs` in the data of its refusal (`throw new PluginError("rate_limited", message, { retryAfterMs })`), and the next try waits that long, within an hour, in place of the backoff; the try still counts.
267
+ - Jobs run in the `worker` role's host, with the `jobs` capability, each under an advisory lock, never two at once, the lock held by a transaction the server lets idle for the job's five minutes and a minute more, in place of the thirty seconds an idle transaction has otherwise; `plugin_jobs` keeps the last run, its outcome and the next time, the first run one period after the scheduler first sees the job, `at` read in the workspace's time zone; a failed job is a `plugin.failed` event, at most one an hour per job, and a line in the log every time. A job has five minutes.
257
268
  - `WORKER_IN_PROCESS` puts hooks and jobs in the `api` role's host.
258
269
 
259
270
  ## Between plugins
260
271
 
261
272
  - `requires` is enabled first and stays enabled: enabling a plugin whose requirement is not enabled, or not in range, is `plugin.requires`; disabling one that another enabled plugin requires is `plugin.required_by`.
262
- - `plugins:<key>` lets a plugin call `ctx.plugins.get(key).api.<name>(input)`; the callee checks `input` with its schema and runs with the caller passed through, so that the callee's roles decide again; a callee that is disabled is `plugin.disabled` to the caller.
273
+ - `plugins:<key>` lets a plugin call `ctx.plugins.get(key).api.<name>(input)`; the callee checks `input` with its schema and runs with the caller passed through: a person as themselves, so that the callee's roles decide again; the console's own call (a job, a hook, a webhook) as the calling plugin, `{ kind: "plugin", plugin, roles, for: null }`, with the roles granted to it in the callee and no more ([ADR-0072](../adr/0072-a-plugin-is-a-principal-it-holds-roles-granted-to-it-calls-as-itself-and-acts-for-a-person-within-what-both-may-do.md)); and `.for(personId)`, with `people:act`, as the plugin for that person, `for` the person and the roles those the plugin and the person both hold, in the person's language. A callee that is disabled is `plugin.disabled` to the caller; a callee may refuse `kind: "plugin"` where its rules need a person. A plugin calls no api of its own: its code runs directly, and through `ctx.plugins` it would be called as another plugin, for anyone with `people:act`; a manifest that asks `plugins:<its key>` is refused, and so is the call.
263
274
  - `events:plugins` is the loose coupling: a plugin emits, another listens; a payload is the emitter's to define and document.
264
275
 
265
276
  ## Lifecycle
@@ -267,7 +278,7 @@ With `storage:sql` consented to, enabling creates the schema `plugin_<key>` and
267
278
  | State | How it comes about | What works |
268
279
  |---|---|---|
269
280
  | installed | the package is in `PLUGINS_DIR` with a manifest, and loads | listed in the settings; nothing else |
270
- | failed | the manifest is invalid, the key is taken, `pluginApi` is not served, `requires` names a plugin that is missing, the server module does not load, or the installed `dataVersion` is newer than the code's | listed with its reasons; a `plugin.failed` event; the console boots on |
281
+ | failed | the manifest is invalid, the key is taken, `pluginApi` is not served, the console is outside `console`, `requires` names a plugin that is missing, the server module does not load, or the installed `dataVersion` is newer than the code's | listed with its reasons; a `plugin.failed` event; the console boots on |
271
282
  | enabled | an administrator consented to the capabilities, bound the board, named the projects and filled the required settings, and `onEnable` ran | everything |
272
283
  | needs consent | a new version asks capabilities the install lacks | nothing but the settings, until consented |
273
284
  | disabled | an administrator disabled it and `onDisable` ran, or a required plugin is gone | nothing; the data kept; the tabs gone; commands `plugin.disabled` |
@@ -279,7 +290,7 @@ Upgrades: the `migrate` role, before the `api` and `worker` roles start on a new
279
290
  ## The host
280
291
 
281
292
  - The `api` and `worker` roles each spawn one child process from the console's own code (`dist/plugin-host.js`), as `node --permission --allow-fs-read=<PLUGINS_DIR>,<each plugin's directory>,<the host's own directory> --allow-worker dist/plugin-host.js`, with an environment of nothing but what Node needs, no database credentials, and no configuration but what the parent sends over the IPC channel: the plugins to load and the limits. A plugin's directory is its real path, so a package linked into `PLUGINS_DIR` from a checkout, as it is while a plugin is written, is read where it is. No `--allow-fs-write`, `--allow-child-process`, `--allow-addons` or `--allow-wasi`.
282
- - The host loads each plugin's server module in a worker thread of its own, with `resourceLimits` from `PLUGIN_MEMORY_MB_AT_MOST`, after hardening the thread: a module hook refuses `node:net`, `node:http`, `node:https`, `node:http2`, `node:tls`, `node:dgram`, `node:dns`, `node:child_process`, `node:cluster`, `node:worker_threads`, `node:vm`, `node:inspector`, `node:repl`, `node:wasi` and `node:v8`; `globalThis.fetch`, `WebSocket`, `EventSource` and `process.binding` are removed; `process.env` is emptied. Reading files is the plugin's own directory, by the permission model; writing is nowhere.
293
+ - The host loads each plugin's server module in a worker thread of its own, with `resourceLimits` from `PLUGIN_MEMORY_MB_AT_MOST`, after hardening the thread: a module hook refuses `node:net`, `node:http`, `node:https`, `node:http2`, `node:tls`, `node:dgram`, `node:dns`, `node:child_process`, `node:cluster`, `node:worker_threads`, `node:vm`, `node:inspector`, `node:repl`, `node:wasi` and `node:v8`, as well as the names Node keeps for compatibility that reach the same network (`node:_http_client` and its kin, `node:_tls_wrap`), by whichever way they are asked for, `process.getBuiltinModule` too; `globalThis.fetch`, `WebSocket`, `EventSource` and `process.binding` are removed; `process.env` is emptied. Reading files is the plugin's own directory, by the permission model; writing is nowhere.
283
294
  - The host's main thread is the broker: a call on `ctx` is a message from the thread to the broker to the parent, which decides and performs it and answers; messages are JSON up to 4 MiB, and files travel as handles the parent resolves.
284
295
  - A thread that runs past its time (`ctx.signal` fires first, then the broker ends it), grows past its memory or crashes is ended alone; the broker starts it afresh, and the call that was running fails as `plugin.timeout` or `plugin.unavailable`. Module-level state does not survive; state is in records.
285
296
  - A host that dies is restarted by its parent; while it is away, every plugin call is `plugin.unavailable`, and the console answers everything else.
@@ -291,19 +302,21 @@ Under `/api/v1/plugins`. Who may call: *anyone* signed in or on a token, *roles*
291
302
 
292
303
  | Route | Who | What |
293
304
  |---|---|---|
294
- | `GET /api/v1/plugins` | anyone | the plugins a member may know of (the enabled ones; every state for an administrator): key, name, description, version, state and reasons, the caller's roles, the menu, the pages and widgets the caller may see, the board project's key; and whether `storage:sql` is available in this deployment |
295
- | `GET /api/v1/plugins/<key>` | anyone | one plugin: the above, its commands and queries (name, about, args), its skills address, its settings without the secrets (whether each is set); for an administrator the consent, the grants and the projects |
305
+ | `GET /api/v1/plugins` | anyone | the plugins a member may know of (the enabled ones; every state for an administrator): key, name, description, version, the consoles it declares it runs on (`console`, or `null`), state and reasons, the caller's roles, the menu, the pages and widgets the caller may see, the board project's key; and whether `storage:sql` is available in this deployment |
306
+ | `GET /api/v1/plugins/<key>` | anyone | one plugin: the above, its commands and queries (name, about, args), its skills address, its settings without the secrets (whether each is set, and for a secret `setAt`, `expiresAt`, `source`, `hosts` and `use`), `needsTimeZone`, whether the plugin reads a time of day while the workspace has no time zone, and `keeps`, the collections it forgets after a while with their days; for an administrator the consent, the grants, the projects and `asks`, how the roles it asks of other plugins stand |
296
307
  | `POST .../enable` | admin | `{ consent: [...], board: { create: { key, name, description } } \| { project: "<key>" } \| null, projects: "all" \| [...], settings: {...} }` |
297
308
  | `POST .../disable` | admin | |
309
+ | `PATCH .../consent` | admin | `{ consent: [...] }`: what an enabled plugin holds from now on, every capability it requires named again and the optional ones to keep on; a `plugin.configured` event with `capabilities` |
298
310
  | `PATCH .../settings` | admin | `{ values }`, without secrets |
299
311
  | `PUT .../secrets/<name>`, `DELETE .../secrets/<name>` | admin | `{ value }`; never read |
300
- | `GET .../grants`, `POST .../grants`, `DELETE .../grants/<id>` | admin | `{ role, person?: id, project?: key, everyone?: true }` |
301
- | `POST .../invoke` | roles | `{ argv: [...], files?: [handles], project?: key, run?: id }`: the words as typed, the command's or the query's name first, parsed by the console against the definition (`parseArgv`: the positionals in declaration order, `--name value` or `--name=value`, a boolean flag alone, a list as the flag repeated or with commas between, `--` ending the flags; a person by login or `me`, a project by key and a file by its handle resolved to what the caller may see or uploaded); the handles of the files uploaded for its `file` arguments, in declaration order; the project the call is on; the run it is for, one of the caller's open in the board project, where several are. `{ kind: "command" \| "query", text, data, waiting }`, a query's file as `data.file` with `name`, `type`, `bytes` and `base64`; or the refusal |
312
+ | `GET .../grants`, `POST .../grants`, `DELETE .../grants/<id>` | admin | `{ role, person?: id, project?: key, plugin?: key, team?: key, everyone?: true }`, one of the five; a grant answers `plugin`, or `team` as `{ key, name }`, where the role went to one |
313
+ | `POST .../invoke` | roles | `{ argv: [...], files?: [handles], project?: key, run?: id }`: the words as typed, the command's or the query's name first, parsed by the console against the definition (`parseArgv`: the positionals in declaration order, `--name value` or `--name=value`, a boolean flag alone, a list as the flag repeated or with commas between, `--` ending the flags; a person by login or `me`, a project by key and a file by its handle resolved to what the caller may see or uploaded); the handles of the files uploaded for its `file` arguments, in declaration order; the project the call is on; the run it is for, one of the caller's open in the board project, where several are. `{ kind: "command" \| "query", text, data, waiting, project }`, `project` the key of the project the decision named in `waiting` waits in, the one it was raised in, its board or another, where the caller reaches the decision and `null` where they do not; with nothing waiting, the key of the plugin's board project, or `null`; a query's file as `data.file` with `name`, `type`, `bytes` and `base64`; or the refusal |
302
314
  | `POST .../commands/<name>` | roles | `{ args, project?, run? }`, the arguments as JSON by name, for the pages; the same answer |
303
315
  | `GET .../queries/<name>?args=<json>&project=` | roles | the query's JSON, or its file as bytes with `content-disposition` |
304
316
  | `GET .../records/<collection>?where=&order=&limit=&after=&q=&project=` | roles | the records the caller may read |
305
317
  | `GET .../records/<collection>/<id>[?version=]`, `GET .../records/<collection>/<id>/versions` | roles | one record, a version of it, its versions |
306
318
  | `POST .../files`, `GET .../files/<id>` | roles | a file for a command (a handle, good for an hour); a record's file |
319
+ | `GET .../people/<id>/picture` | roles, `people:read` | the picture of a person the asker may see, or of anyone with `people:read:all`; it names the person, never an address ([ADR-0063](../adr/0063-a-frame-reads-a-persons-picture-by-the-person-and-a-plugin-reads-who-it-granted-a-role.md)) |
307
320
  | `GET .../events?limit=&offset=` | roles | the plugin's events |
308
321
  | `GET .../plugin-events?after=&limit=` | roles | the events the plugin emitted (`ctx.events.emit`), after a number, oldest first: `{ items: [{ id, kind, data, at }], more }` |
309
322
  | `GET .../skills`, `.../skills/one?path=&cursor=`, `.../skills/file?path=&offset=&limit=`, `.../skills/browse?path=&cursor=&limit=`, `.../skills/find?query=&path=&cursor=&limit=` | anyone who may know of the plugin | the plugin's skills, a pack of its own at `/local/plugins/<key>`, read from its package on the console's machine as a `local` pack is ([ADR-0031](../adr/0031-the-console-reads-a-pack-as-the-skillcdn-command-does-and-a-local-one-from-a-directory.md)), answered as a project's five reads are; `status: none` for a plugin that ships none |
@@ -311,7 +324,7 @@ Under `/api/v1/plugins`. Who may call: *anyone* signed in or on a token, *roles*
311
324
  | `GET .../export` | admin | JSON Lines, and the list of the files |
312
325
  | `POST .../purge` | admin | `{ confirm: "<key>" }` |
313
326
  | `GET .../frame?page=<key>\|widget=<key>&project=<key>` | anyone, no session | the frame document |
314
- | `GET .../web/<version>/<path>` | anyone, no session | a file of the bundle, immutable |
327
+ | `GET .../web/<version>/<path>` | anyone, no session | a file of the bundle, immutable; a script of a plugin that lists `outbound:webrtc` as optional is kept nowhere |
315
328
  | `GET /api/v1/plugin-runtime/console.css` | anyone, no session | the console's stylesheet, for frames |
316
329
  | `GET /api/v1/plugin-runtime/fonts.css` | anyone, no session | the faces of the console's own build, for frames; empty where the server has no build |
317
330
  | `GET /api/v1/plugin-runtime/assets/<file>` | anyone, no session | a font of the console's build, immutable |
@@ -323,12 +336,12 @@ Under `/api/v1/plugins`. Who may call: *anyone* signed in or on a token, *roles*
323
336
  | Command | Asks | Says |
324
337
  |---|---|---|
325
338
  | `plugins` | `GET /api/v1/plugins` | a line per plugin: key, name, version, state, the caller's roles |
326
- | `plugin <key>` | `GET /api/v1/plugins/<key>` | the description and the agent note; a line per command and query; the skills (`console plugin <key> skills`); the board project; the settings an agent should know |
327
- | `plugin <key> help [<command>]` | the same | one command whole, in the form of `console help <command>`: the forms, the arguments, the limits, the refusals |
328
- | `plugin <key> <command> [args] [--<flag> <value>]... [--file <path> \| -] [--run <id>] [--json]` | `POST .../files` where `--file` or `-`, then `POST .../invoke` with the words as typed, the handle, the project the command works in and the run named | the command's `text`, or its `data` with `--json`; a query's answer as JSON, a file it answers saved under its name; exit `0`; `3` with `waiting`, printing the decision's id and `console decision <id> --wait`; `1` on a refusal, in the console's form. `--json`, `--url`, `--account`, `--project`, `--file` and `--run` are the command's own wherever they stand; every other word is the plugin's |
339
+ | `plugin <key>` | `GET /api/v1/plugins/<key>` | the description and the agent note; a line per command and query, its usage in brief, the positionals and the required flags, the rest as `[options]`, since an agent reads this whole at every first use; the skills (`console plugin <key> skills`); the board project; the settings an agent should know |
340
+ | `plugin <key> help [<command>]` | the same | one command whole, in the form of `console help <command>`: the forms, every argument, the limits, the refusals; without a command, every one in the brief form |
341
+ | `plugin <key> <command> [args] [--<flag> <value>]... [--file <path> \| -] [--run <id>] [--json]` | `POST .../files` where `--file` or `-`, then `POST .../invoke` with the words as typed, the handle, the project the command works in and the run named | the command's `text`, or its `data` with `--json`; a query's answer as JSON, a file it answers saved under its name; exit `0`; `3` with `waiting`, printing the decision's id, the project it waits in and `console decision <id> --wait`, which reads the decision wherever it is, from a checkout bound to another project or none; `1` on a refusal, in the console's form. `--json`, `--url`, `--account`, `--project`, `--file` and `--run` are the command's own wherever they stand; every other word is the plugin's |
329
342
  | `plugin <key> records <collection> [<id>] [--where <field>=<value>]... [--search <words>] [--project <key>] [--order <field>[:desc]] [--limit <n>] [--after <cursor>] [--archived] [--versions]` | `GET .../records/...` | the records, one a line, or one whole, or its versions |
330
343
  | `plugin <key> skills [--search <words>]`, `plugin <key> skill <path>` | `GET .../skills...` | as `console skills` and `console skill` |
331
- | `plugin <key> enable [--consent <capability>,...] [--board new\|<key>] [--projects all\|<key>,...] [--set <key>=<value>]...`, `plugin <key> disable`, `plugin <key> settings [--set ...]`, `plugin <key> secret <name>` (the value from standard input), `plugin <key> grant <role> --person <login>\|--project <key>\|--everyone`, `plugin <key> revoke <grant>` | the administrator's routes | what changed; `enable` prints what each capability opens before asking the console, and refuses without `--consent` naming every capability the manifest asks |
344
+ | `plugin <key> enable [--consent <capability>,...] [--board new\|<key>] [--projects all\|<key>,...] [--set <key>=<value>]...`, `plugin <key> disable`, `plugin <key> capabilities [--consent <capability>,...|none]`, `plugin <key> settings [--set ...]`, `plugin <key> secret <name>` (the value from standard input), `plugin <key> grant <role> --person <login>\|--project <key>\|--everyone`, `plugin <key> revoke <grant>` | the administrator's routes | what changed; `enable` prints what each capability opens before asking the console, and refuses without `--consent` naming every capability the manifest requires; `capabilities` says what the plugin holds, and with `--consent` which of the optional ones it holds from now on |
332
345
 
333
346
  The rules an agent reads carry the plugins: `GET /api/v1/rules` and `GET /api/v1/projects/<key>/rules` answer `plugins`, each with its `key`, `name`, `agentNote` and `description` by language and the caller's `roles` in it, and `console take` and `console rules` print, after the project's rules, a section `## Plugins` with a line per plugin bound to the project (as its board project, or on it) that the caller holds a role in, `- <key> (<name>): <agentNote> Start with console plugin <key>.`, the note in the caller's language where the manifest has it, the description where it has no note; `console rules` at the workspace lists every plugin the caller may use. Nothing else of a plugin reaches an agent unasked. `console help` names the two commands in a section of their own, and `console skills` ends with where a plugin's skills are. The skill `working-the-board` says: when the rules name a plugin, read `console plugin <key>` and its skills before its first command.
334
347
 
@@ -351,7 +364,7 @@ The rules an agent reads carry the plugins: `GET /api/v1/rules` and `GET /api/v1
351
364
  </html>
352
365
  ```
353
366
 
354
- with the headers `content-security-policy: default-src 'none'; script-src <origin>/api/v1/plugins/<key>/web/; style-src <origin>/api/v1/plugins/<key>/web/ <origin>/api/v1/plugin-runtime/; img-src <origin>/api/v1/plugins/<key>/web/ data: blob:; font-src <origin>/api/v1/plugins/<key>/web/ <origin>/api/v1/plugin-runtime/; connect-src 'none'; frame-ancestors <origin>; base-uri 'none'; form-action 'none'; sandbox allow-scripts allow-forms`, `referrer-policy: no-referrer`, `x-content-type-options: nosniff` and `cache-control: no-store`. `<origin>` is the origin of the page that embeds the frame, read from the request's `referer`: `PUBLIC_URL`'s, or the loopback a person's own console is served on by `console serve` ([ADR-0014](../adr/0014-a-persons-own-console-is-served-from-their-machine-by-the-command.md)); any other, or none, is not found. The console's page embeds the frame as `<iframe sandbox="allow-scripts allow-forms" referrerpolicy="origin" src=...>`. The document's origin is opaque: no cookie travels, the API is out of reach, and its scripts are the bundle's alone. `allow-forms` is there so that a page's form fires its `submit` event, which a browser drops with the submission otherwise; `form-action 'none'` keeps a submission from going anywhere, so a page handles the event itself and speaks over the bridge. The headers are proven by an integration test, and a browser is seen to enforce them by `pnpm test:browser`, a real Chromium against the console listening on this machine, apart from `pnpm check` (`apps/console/src/http/plugin-frame.browser.test.ts`). The files of the bundle, `GET .../web/<version>/<path>`, are served from the package's `web` directory with `cache-control: public, max-age=31536000, immutable`, `cross-origin-resource-policy: cross-origin` and `access-control-allow-origin: *`, since the frame's origin is not the console's and a module script, as a font, is fetched with CORS; the console's stylesheet for frames carries the same two. The document links `fonts.css` before the stylesheet: the `@font-face` rules of the console's own build, their files served at `GET /api/v1/plugin-runtime/assets/<file>`, fonts alone, so that a frame reads in the console's faces; empty where the server has no build. In a frame document the stylesheet paints no background and lets the document be as tall as its content (`html[data-console]`), so that the box the console draws shows through and the frame shrinks as well as grows. A bundle holds no secret, since anyone may fetch it. The console's own pages gain `frame-src 'self'` in their policy and nothing else.
367
+ with the headers `content-security-policy: default-src 'none'; script-src <origin>/api/v1/plugins/<key>/web/<version>/; style-src <origin>/api/v1/plugins/<key>/web/ <origin>/api/v1/plugin-runtime/; img-src <origin>/api/v1/plugins/<key>/web/ data: blob:; font-src <origin>/api/v1/plugins/<key>/web/ <origin>/api/v1/plugin-runtime/; connect-src 'none'; frame-ancestors <origin>; base-uri 'none'; form-action 'none'; sandbox allow-scripts allow-forms`, `referrer-policy: no-referrer`, `x-content-type-options: nosniff` and `cache-control: no-store`. `<origin>` is the origin of the page that embeds the frame, read from the request's `referer`: `PUBLIC_URL`'s, or the loopback a person's own console is served on by `console serve` ([ADR-0014](../adr/0014-a-persons-own-console-is-served-from-their-machine-by-the-command.md)); any other, or none, is not found. A plugin that is not enabled, or a page or a widget it does not declare, is answered 404 with a blank document under `default-src 'none'; script-src 'sha256-<its one script>'; frame-ancestors <origin>; base-uri 'none'; form-action 'none'; sandbox allow-scripts`, whose one script tells the page `unavailable`, so that a frame never shows an answer meant for a program. The console's page embeds the frame as `<iframe sandbox="allow-scripts allow-forms" referrerpolicy="origin" src=...>`. The document's origin is opaque: no cookie travels, the API is out of reach, and its scripts are the bundle's alone. `allow-forms` is there so that a page's form fires its `submit` event, which a browser drops with the submission otherwise; `form-action 'none'` keeps a submission from going anywhere, so a page handles the event itself and speaks over the bridge. The headers are proven by an integration test, and a browser is seen to enforce them by `pnpm test:browser`, a real Chromium against the console listening on this machine, apart from `pnpm check` (`apps/console/src/http/plugin-frame.browser.test.ts`). The files of the bundle, `GET .../web/<version>/<path>`, are served from the package's `web` directory with `cache-control: public, max-age=31536000, immutable` (`no-store` on the preview console of `pnpm dev:plugins`, where a plugin is rebuilt under one version and a browser that kept a build would run it against a server that answers something else), `cross-origin-resource-policy: cross-origin` and `access-control-allow-origin: *`, since the frame's origin is not the console's and a module script, as a font, is fetched with CORS; the console's stylesheet for frames carries the same two. The document links `fonts.css` before the stylesheet: the `@font-face` rules of the console's own build, their files served at `GET /api/v1/plugin-runtime/assets/<file>`, fonts alone, so that a frame reads in the console's faces; empty where the server has no build. In a frame document the stylesheet paints no background and lets the document be as tall as its content (`html[data-console]`), so that the box the console draws shows through and the frame shrinks as well as grows. A bundle holds no secret, since anyone may fetch it. The console's own pages gain `frame-src 'self'` in their policy and nothing else.
355
368
 
356
369
  ### The bridge
357
370
 
@@ -359,42 +372,49 @@ The console's page and the frame speak over `postMessage`, every message `{ v: 1
359
372
 
360
373
  | From | `t` | Carries |
361
374
  |---|---|---|
362
- | the page | `init` | `plugin: { key, version }`, `page` or `widget`, `project?: { key, name }`, `person: { id, login, name, language, admin }`, `roles`, `path` (what follows the page in the address), `language`, `theme: { [token]: value }` |
375
+ | the page | `init` | `plugin: { key, version }`, `page` or `widget` (with `size`, the size the widget has, [ADR-0059](../adr/0059-a-person-arranges-the-front-pages-widgets-from-the-sizes-each-comes-in.md)), `project?: { key, name }`, `person: { id, login, name, language, admin }`, `workspace: { name, languages, timeZone, weekStart, firstDay }` (`firstDay` the day a week begins on with the workspace's setting applied, 1 for Monday, 7 for Sunday, for a calendar to start from; none from a console older than it), `roles`, `path` (what follows the page in the address), `language`, `theme: { [token]: value }` |
363
376
  | the page | `result` | `id`, `ok`, `value` or `error: { code, message }` |
364
377
  | the page | `event` | `kind: "records" \| "command" \| "plugin"`, `data`: something of this plugin changed. `command` and `records` come from the project's feed, on every `plugin.command` of the plugin on the project the page is on; `plugin` from `GET .../plugin-events`, asked for every few seconds while a frame listens |
365
378
  | the page | `theme` | the tokens, when the console's look changes |
366
379
  | the page | `path` | `path`, when the console navigated within this page's address |
380
+ | the page | `size` | `size`, when the widget has another of the sizes it comes in: the person chose it, or the page's width changed the grid |
367
381
  | the frame | `ready` | nothing; the page answers with `init` |
368
- | the frame | `call` | `id`, `method`, `params`: `command` (`name`, `args`), `query` (`name`, `args`), `records.list`, `records.get`, `records.versions`, `files.get` (`id`; the bytes come back transferred), `files.put` (`bytes`, `name`, `type`; a handle comes back) |
382
+ | the frame | `unavailable` | nothing; the page shows a line of its own in place of the frame. The document the console serves when there is nothing to show says it: a plugin that is off, a page or a widget it has not |
383
+ | the frame | `call` | `id`, `method`, `params`: `command` (`name`, `args`), `query` (`name`, `args`), `records.list`, `records.get`, `records.versions`, `files.get` (`id`; the bytes come back transferred), `files.put` (`bytes`, `name`, `type`; a handle comes back), `people.picture` (`id` of a person; the bytes and their type come back transferred) |
369
384
  | the frame | `navigate` | `path`: a path of the console's, which the page follows in place; a URL is refused |
370
- | the frame | `resize` | `height`, which the page gives the frame, up to a bound for a widget |
385
+ | the frame | `resize` | `height`, which the page gives a page's frame; a widget fills the cells its size gives it, and its height is not read |
371
386
  | the frame | `title` | `text`, for the document's title |
372
387
  | the frame | `download` | `id` of a file, which the page downloads |
388
+ | the frame | `open` | `url`, a web or a mail address (`https:`, `http:`, `mailto:`) a person pressed, which the page opens in a new tab with no opener and no referrer, only right after a press ([ADR-0067](../adr/0067-a-link-a-person-presses-in-a-frame-opens-in-a-new-tab-through-the-bridge.md)); anything else is dropped unread, an address of the console's own origin among it, since a path of the console's is `navigate`'s |
373
389
  | the frame | `subscribe` | `kinds`, to receive `event` |
374
390
 
375
391
  The page performs every `call` with the person's session under `/api/v1/plugins/<key>/` only; a method or a path outside it is refused before any request, as `plugin.forbidden`. A page thus holds exactly the authority of its plugin's server module, as the person using it.
376
392
 
377
- `@skillcdn/console/plugin/web` is the frame's side: `connect()` says `ready`, waits for `init`, and resolves to `{ context, call, command, query, records, files, navigate, download, setTitle, onEvent, onTheme, onPath, close }`, applies the tokens to `:root` and the language to the document, and watches the body's height; it depends on nothing, and a refusal arrives as a `PluginBridgeError` with the console's code. `@skillcdn/console/plugin/web/react` adds `usePlugin()` for a plugin that chooses React. A page renders nothing a person wrote as HTML, as the console's own pages do not.
393
+ `@skillcdn/console/plugin/web` is the frame's side: `connect()` says `ready`, waits for `init`, and resolves to `{ context, call, command, query, records, files, people, navigate, open, download, setTitle, onEvent, onTheme, onPath, close }`, applies the tokens to `:root` and the language to the document, and watches the body's height; it depends on nothing, and a refusal arrives as a `PluginBridgeError` with the console's code. `@skillcdn/console/plugin/web/react` adds `usePlugin()` for a plugin that chooses React. `people.picture(id)` answers a `blob:` address for an image, or `null` for a person with no picture to show. A page renders nothing a person wrote as HTML, as the console's own pages do not.
378
394
 
379
395
  ### Tabs, sub-tabs and addresses
380
396
 
381
397
  - At the workspace, after the console's own tabs and the organization's pages, and before Settings, which is last, come the groups that hold a plugin the person has a role in; in a project the same, before the project's settings. A group with one plugin is a tab with the plugin's name, leading to its first page; a group with several is a tab with the group's name that opens the list of its plugins, each leading to its first page (a `<details>`, as the person's menu is; `NavItem.children`). The groups the console knows have names in every language pack; a group a plugin declares has the plugin's. Groups and plugins are ordered by `menu.order`, then by key.
382
- - A plugin with several pages at a level has a row of sub-tabs the console draws above the frame, in the manifest's order; a page's `wide` widens the frame as `CustomPage.wide` does.
398
+ - A plugin with several pages at a level has a row of sub-tabs the console draws above the frame, in the manifest's order, joined to the header's tabs; a page's `wide` widens the frame as `CustomPage.wide` does. Views within a page are the plugin's own: `sc-subtabs` around `sc-tab`s, links or buttons, with `sc-tab-current` on the one shown, is the console's view switch.
383
399
  - Addresses: `/plugins/<key>` is the first page, `/plugins/<key>/<page>` a page, `/plugins/<key>/<page>/<rest>` a page with `<rest>` handed to the frame as `path`; on a project, `/projects/<pkey>/plugins/<key>[/<page>[/<rest>]]`. The custom pages of a console keep `/pages/<key>` ([ADR-0010](../adr/0010-the-default-uis-addresses-name-what-they-show-and-every-page-and-form-has-one.md)).
384
400
  - On a project, the tab row gains the same, for the plugins on that project with `level: project` pages, to the members who hold a role.
385
401
  - A person without a role sees no tab, and a plugin's address answers not found, as a project they may not see does.
386
402
 
387
403
  ### Widgets
388
404
 
389
- The front page (`/`) shows the widgets the person holds a role for, `home:top` above the projects and `home:bottom` below, in a grid of six columns: `third` takes two, `half` three, `full` six. Each widget is a frame with the same document and bridge, `widget=<key>` in place of `page`, its name above it, its height what it asks up to 480 pixels.
405
+ The front page (`/`) is one grid of square cells on which the projects and the widgets stand as tiles ([ADR-0061](../adr/0061-the-front-page-is-one-grid-of-tiles-a-person-arranges-by-hand.md)): the widgets the person holds a role for, of the plugins that are enabled. The grid has 12 columns where the page is at least 1520 pixels wide, 8 from 900, 6 from 560 and 4 below, and a cell is as wide as that leaves it. A project is an `icon` of 1×1 cells or `small` of 2×2. A widget is at its size, `small` 2×2, `medium` 4×2, `large` 4×4, `wide` 8×2, `full` 8×4, filling the cells it has; the height it says is not read. A grid offers a tile the sizes that fit it, and one kept or started at a size too wide takes the largest it comes in that fits, each width being arranged for itself; every widget has a size at most four cells across, so that a phone has one to show ([ADR-0062](../adr/0062-a-widget-comes-in-a-size-a-phone-can-hold.md)). Each widget is a frame with the same document and bridge, `widget=<key>` in place of `page`, its name above it; `init` carries `size`, the size the widget has on this grid, and a `size` message follows when the person changes it or the page narrows past it, so that the bundle lays itself out for the cells (`frame.context.size`, `frame.onSize`).
406
+
407
+ There is no default layout. What the person has not seen yet takes the first room reading from the top, at the size it starts at: a project made since, the widgets of a plugin as soon as it is enabled. For someone who has arranged nothing, `home:top` puts a widget before the projects and `home:bottom` after them. `GET /api/v1/me` answers `home`, `{ hidden: [<tile>, ...], layouts: { "12"?, "8"?, "6"?, "4"?: [{ tile, x, y, size }, ...] } }`, or `null` while they have arranged nothing: the tiles they took off, which stay off, and what they arranged at each width, a tile being `project:<id>` or `widget:<plugin>:<widget>`; `PATCH /api/v1/me` with `home` keeps it. A width nobody arranged reads the same order packed from the top.
408
+
409
+ Edit layout opens nothing on the page. A tile is taken in hand with the mouse, or with a finger that rests on it a moment first, and put down on any cell; empty cells stay empty. Where it would land shows the tile, faint, and those in the way slide aside as it comes, the nearest first. On a tile of its own shape, the tiles of that shape between the two places each move one place towards the one it left; a smaller tile gives way to the nearest room, and a larger one stays, the tile landing beside it. The arrow keys move the tile with the focus a cell, and Delete takes it off. Each tile carries the way off the page and the sizes it comes in, what was taken off is listed to add back, Done keeps it all and Cancel lets it go. A tile the person may no longer see, for a role gone or a plugin disabled, is out of sight and comes back where it stood.
390
410
 
391
411
  ### The look
392
412
 
393
- The console's page reads its computed `--sc-*` tokens (the list is `CONSOLE_TOKENS` in the package) and sends them in `init.theme` and in `theme`; the frame's SDK sets them on `:root`, so that a custom console's look reaches its plugins' pages. `GET /api/v1/plugin-runtime/console.css` is the package's `console.css`, so that the `sc-*` classes work in a frame. Tokens added to the console's set for plugins: `--sc-color-info` and `--sc-color-info-subtle`; `--sc-color-neutral` and `--sc-color-neutral-subtle`, a state neither good nor bad; `--sc-series-1` to `--sc-series-6`, a palette for figures; `--sc-numeric`, the font features for tabular numerals. A plugin's own classes begin with `pl-<key>-`; `console-plugin check` warns of others. The frame's `lang` is the person's language, and the console shows the manifest's names in it; the words inside the frame are the plugin's, from `init.language`.
413
+ The console's page reads its computed `--sc-*` tokens (the list is `CONSOLE_TOKENS` in the package) and sends them in `init.theme` and in `theme`; the frame's SDK sets them on `:root`, so that a custom console's look reaches its plugins' pages. `GET /api/v1/plugin-runtime/console.css` is the package's `console.css`, so that the `sc-*` classes work in a frame. Tokens added to the console's set for plugins: `--sc-color-info` and `--sc-color-info-subtle`; `--sc-color-neutral` and `--sc-color-neutral-subtle`, a state neither good nor bad; `--sc-series-1` to `--sc-series-6`, a palette for figures; `--sc-numeric`, the font features for tabular numerals. A plugin's own classes begin with `pl-<key>-`; `console-plugin check` warns of others. A page pads itself by `--sc-space-4` on every side, for the shadows of what is in it, which the frame would cut; the frame of a page stands that far outside the page's column, so that the page's first words line up with the tabs above it. A widget pads nothing: the box the console draws around it does. The frame's `lang` is the person's language, and the console shows the manifest's names in it; the words inside the frame are the plugin's, from `init.language`.
394
414
 
395
415
  ### The settings pages
396
416
 
397
- The Settings page lists the plugins with their state, for an administrator, each leading to its own page, and `/settings/plugins` is that list alone; `/settings/plugins/<key>` is one, read at a glance and with as few words as it takes: its name, key, version and state in the head, with the way to disable it; why it cannot run, where it cannot; a row each for its board project, the projects it is on, the capabilities it holds, its commands and queries, and what agents read of it; where it is not enabled, the form that enables it, in three parts: the capabilities, each by the name the pages give it in the person's language with one sentence of what it opens (the packs' `plugins.capability`; the catalogue above is what they say), ticked one by one, those consented to before ticked already; the board project, to make (the form filled from `board.defaults`) or to choose; the projects, all or some; the settings form from the manifest, and each secret with when it was set, never its value; the roles the plugin declares, each with what it is for and who holds it, every member for `defaultRole`, each grant (a person, a project's members, everyone) a chip with the way to revoke it, and one choice to grant the role to someone not holding it yet; the plugin's events; export and purge, the latter offered once the plugin is disabled and asking for the key to be typed. Administrators only; no plugin code runs on these pages.
417
+ The Settings page lists the plugins with their state, for an administrator, each leading to its own page, and `/settings/plugins` is that list alone; `/settings/plugins/<key>` is one, read at a glance and with as few words as it takes: its name, key, version and state in the head, with the way to disable it; why it cannot run, where it cannot; a row each for its board project, the projects it is on, the capabilities it holds, those it can go without each with a box to turn it on or off, its commands and queries, and what agents read of it; where it is not enabled, the form that enables it, in three parts: the capabilities, each by the name the pages give it in the person's language with one sentence of what it opens (the packs' `plugins.capability`; the catalogue above is what they say), in two lists: those it requires, on and not the administrator's to turn off, one a new version asks marked as new, and those it can go without, off until turned on, those consented to before on already; the board project, to make (the form filled from `board.defaults`) or to choose; the projects, all or some; the settings form from the manifest, and each secret with when it was set, never its value; the roles the plugin declares, each with what it is for and who holds it, every member for `defaultRole`, each grant (a person, a project's members, everyone) a chip with the way to revoke it, and one choice to grant the role to someone not holding it yet; the plugin's events; export and purge, the latter offered once the plugin is disabled and asking for the key to be typed. Administrators only; no plugin code runs on these pages.
398
418
 
399
419
  ## Security model
400
420
 
@@ -404,7 +424,7 @@ The install path is the first line (a release of the deployment, from a lockfile
404
424
 
405
425
  | | Held by |
406
426
  |---|---|
407
- | read a secret, a credential or the environment | the host's empty environment; secrets used by the parent, never sent |
427
+ | read a secret, a credential or the environment | the host's empty environment; secrets used by the parent, never sent, and taken out of what an answer sends back; what an answer is to give up, withheld where it cannot be sealed |
408
428
  | reach the database | no connection in the host; `ctx.sql` on the plugin's own role |
409
429
  | read a file outside its own directory, or write any file | the permission model |
410
430
  | start a process, load an addon, open a socket | the permission model, the module hook, the removed globals |
@@ -415,11 +435,11 @@ The install path is the first line (a release of the deployment, from a lockfile
415
435
  | hold the console: run past its time, grow past its memory, send more than fits | the bounds; the thread ended alone |
416
436
  | hide: run a command, change the board or fail without a trace | the events and the log |
417
437
 
418
- **The reach, by capability,** is the last column of the catalogue above. In sum: a plugin reaches what its role holders may see through it and what its consented capabilities open; sending anything out needs an `outbound` host the administrator consented to; what it does on a board is marked and reversible.
438
+ **The reach, by capability,** is the last column of the catalogue above. In sum: a plugin reaches what its role holders may see through it and what its consented capabilities open; sending anything out needs an `outbound` capability the administrator consented to; what it does on a board is marked and reversible.
419
439
 
420
440
  **What is checked, not enforced.** The hardening of a thread is defence in depth: a sandbox of its own would be a host container without egress, left to later. `console-plugin check` scans a plugin for what the host refuses, so that an author learns before an administrator does.
421
441
 
422
- **The pages.** A frame's code runs in an opaque origin and reaches the console through the bridge alone, which performs its plugin's own routes and nothing else; it holds the person's authority within its plugin, exactly as the plugin's server does. Nothing a plugin writes is rendered as HTML by the console's pages: a tab, a sub-tab and a widget's name are text.
442
+ **The pages.** A frame's code runs in an opaque origin and reaches the console through the bridge alone, which performs its plugin's own routes and nothing else; it holds the person's authority within its plugin, exactly as the plugin's server does. A file of its bundle, which a frame may go to, carries a policy of its own and runs nothing as a document. A frame holds no WebRTC either, which `connect-src` does not govern and no policy a browser honours closes: every script of a bundle is served beginning with a line that takes it out of wherever the script runs, the frame or a frame made inside it ([ADR-0064](../adr/0064-a-frame-holds-no-webrtc-every-script-of-a-bundle-begins-by-taking-it-out.md)). A plugin that holds `outbound:webrtc` is served its scripts without that line; no host opens it, and it opens no host. One that lists it as optional is served its scripts uncached, so that it is gone from the next time a frame loads once it is withdrawn; a consent a new version no longer asks opens nothing, and a frame runs its own version's scripts alone ([ADR-0066](../adr/0066-webrtc-is-a-way-out-a-plugin-asks-for-and-how-a-frame-is-held-stays-in-one-place.md)). Nothing a plugin writes is rendered as HTML by the console's pages: a tab, a sub-tab and a widget's name are text.
423
443
 
424
444
  **The record.** Who did what, through which plugin, is on the board: `plugin.command` for every command, the board's own events with the plugin's key for every change, `plugin.enabled` and its kin for every administrative act. The settings page shows a plugin's events, and a feed is filtered by plugin. What was read, queried, received and run on schedule is in the operational log, a line each, with the plugin, the person, the name, the duration and the outcome.
425
445
 
@@ -433,10 +453,11 @@ The install path is the first line (a release of the deployment, from a lockfile
433
453
  | a plugin's memory | `PLUGIN_MEMORY_MB_AT_MOST`, 256 unless set |
434
454
  | records in a plugin; a record; versions of a record; the files' bytes in a plugin | 100,000; 64 KiB; 100; `PLUGIN_FILE_BYTES_AT_MOST`, none unless set |
435
455
  | a plugin's own events | 100,000 kept, 90 days |
456
+ | a collection's retention | 1 to 3650 days; swept hourly |
436
457
  | outbound requests; an answer; its time | 60 a minute per plugin; 10 MiB; 10 s |
437
458
  | webhook requests | 600 a minute per plugin |
438
459
  | `ctx.log` | 100 lines a minute |
439
- | collections; commands and queries; endpoints; pages; widgets; roles; settings; indexes of a collection | 50; 100; 20; 12; 4; 10; 50; 8 |
460
+ | collections; commands and queries; endpoints; pages; widgets; roles; settings; groups of settings; indexes of a collection | 50; 100; 20; 12; 4; 10; 50; 10; 8 |
440
461
  | SQL: a statement; a result; connections | 10 s; 10,000 rows and 4 MiB; 4 |
441
462
  | a page of a list | 200 |
442
463
 
@@ -459,12 +480,12 @@ The install path is the first line (a release of the deployment, from a lockfile
459
480
 
460
481
  ## Authoring
461
482
 
462
- - `console-plugin init <dir> --key <key> [--name <name>] [--console <dependency>] [--package <name>] [--repository <url> [--directory <path>]] [--languages <tags>]` writes a plugin from the template in the package (`template/plugin`, the words `__package__`, `__key__`, `__name__` and `__console__` replaced in its files and their names): the manifest, a server module with one collection, one command and one query, the source of a web bundle with the bridge client, one page and one widget, one skill with `SKILLCDN.md`, tests against `@skillcdn/console/plugin/testing` and of the package as a console reads it, and the build with esbuild (`build.mjs`: the server module, the web bundle and its stylesheet). `examples/plugin-hello` in the repository is that result, built, and the check holds the two equal.
483
+ - `console-plugin init <dir> --key <key> [--name <name>] [--console <dependency>] [--package <name>] [--repository <url> [--directory <path>]] [--languages <tags>]` writes a plugin from the template in the package (`template/plugin`, the words `__package__`, `__key__`, `__name__`, `__console__` and `__consoleRange__` replaced in its files and their names; `--console` is the `devDependency` on `@skillcdn/console`, `^<the command's version>` unless said, and the manifest's `console` is the line of the version it names, `>=0.4.3 <0.5.0` for `^0.4.3`, left out where the dependency names no version, as `workspace:*` does not): the manifest, a server module with one collection, one command and one query, the source of a web bundle with the bridge client, one page and one widget, one skill with `SKILLCDN.md`, tests against `@skillcdn/console/plugin/testing` and of the package as a console reads it, and the build with esbuild (`build.mjs`: the server module, the web bundle and its stylesheet). `examples/plugin-hello` in the repository is that result, built, and the check holds the two equal.
463
484
  - `init` takes beside the key what a plugin bound for a repository or a registry would otherwise have adapted by hand: `--package <name>`, the package's name (`console-plugin-<key>` unless said; a scoped name, `@<scope>/console-plugin-<key>`, gets `publishConfig.access: "public"`); `--repository <url>` with `--directory <path>` in a repository of several, which fill `package.json`'s `repository`, `homepage` and `bugs` and the manifest's `store.homepage` and `store.repository`; and `--languages en,ko`, the languages of the plugin's words, `en` first, which gives every language map of the manifest and the `translations` of `SKILLCDN.md` and of the skill the other languages as the English words, for the author to translate. `check` warns of a translation left the same as English.
464
- - `console-plugin check [<dir>]` reads the package as the console would and fails on: an invalid manifest; a reserved or malformed key; a `pluginApi` the command does not know; a server module that does not load under the host's hardening, or yields a definition naming a role, a setting, a collection or a page that is not declared; a built-in the host refuses, imported anywhere in the server bundle; a web bundle that is not self-contained (a script, a style or an import from elsewhere; `fetch`, `XMLHttpRequest`, `WebSocket` or `document.cookie` in it); a class without the `pl-<key>-` prefix; skills that `skillcdn check` refuses; a license that is not permissive; a `files` field that leaves out what runs. It is what a store would ask of a plugin. It needs Node.js 24 or newer; it warns, and does not fail, on a private package, a class without the prefix, a capability no definition uses, and skills left out; it exits `1` with every problem, `0` with a summary.
465
- - `createTestContext({ collections?, plugin?, caller?, console?, sql?, fetch?, secrets?, plugins?, now?, language? })` from `@skillcdn/console/plugin/testing` is a `ctx` for a plugin's own tests: records in memory, checked against the schema and the indexes as the console checks them, versions, links and files kept; the console answering what the test provided and refusing the rest as `plugin.forbidden`; `emitted` and `logged` to assert on; `all(collection)` for every record; `as(caller)` for the same store as someone else.
485
+ - `console-plugin check [<dir>]` reads the package as the console would and fails on: an invalid manifest; a reserved or malformed key; a `pluginApi` the command does not know; a `devDependency` on `@skillcdn/console` outside the consoles the manifest's `console` declares; a server module that does not load under the host's hardening, or yields a definition naming a role, a setting, a collection or a page that is not declared; a built-in the host refuses, imported anywhere in the server bundle; a web bundle that is not self-contained (a script, a style or an import from elsewhere; `fetch`, `XMLHttpRequest`, `WebSocket` or `document.cookie` in it); a class without the `pl-<key>-` prefix; skills that `skillcdn check` refuses; a license that is not permissive; a `files` field that leaves out what runs. It is what a store would ask of a plugin. It needs Node.js 24 or newer; it warns, and does not fail, on a private package, a class without the prefix, a capability no definition uses, skills left out, and a `console` range that leaves out the console the command belongs to; it exits `1` with every problem, `0` with a summary.
486
+ - `createTestContext({ collections?, plugin?, caller?, console?, sql?, fetch?, secrets?, plugins?, publicUrl?, endpoints?, now?, language? })` from `@skillcdn/console/plugin/testing` is a `ctx` for a plugin's own tests (`publicUrl` and `endpoints`, the origin and the names `ctx.endpoints.url` answers from): records in memory, checked against the schema and the indexes as the console checks them, versions, links and files kept; the console answering what the test provided and refusing the rest as `plugin.forbidden`; `emitted` and `logged` to assert on; `all(collection)` for every record; `as(caller)` for the same store as someone else.
466
487
  - The skill `skills/building-a-plugin` is the map: what to ask, how to split a feature into commands, queries, decisions, tasks and jobs so that agents work and people decide, records or SQL, running a local console with the plugin linked into `PLUGINS_DIR`, the tests, `check`, publishing or tagging, and handing the install to the deployment as a task. It names this document for every field and bound, and repeats none.
467
- - Before 1.0 the plugin API moves with the console's line; `pluginApi` is raised when the contract breaks, and the console names the majors it serves; a plugin is rebuilt against the console it runs on, with no floor between them. The generic routes and the command are in the REST snapshot, so an addition stays an addition.
488
+ - Before 1.0 the plugin API moves with the console's line; `pluginApi` is raised when the contract breaks, and the console names the majors it serves; a plugin is rebuilt against the console it runs on, and names the consoles it runs on in `console`, which carries the patch floor a feature of the console's puts under it (an option's `key`, say, which 0.4.3 brought), where `pluginApi` cannot. The generic routes and the command are in the REST snapshot, so an addition stays an addition.
468
489
 
469
490
  ## Environment
470
491
 
@@ -474,7 +495,7 @@ The install path is the first line (a release of the deployment, from a lockfile
474
495
  | `PLUGIN_FILE_BYTES_AT_MOST` | unset | the bytes a plugin's files may hold together; unset, no bound |
475
496
  | `PLUGIN_MEMORY_MB_AT_MOST` | 256 | the memory a plugin's thread may hold |
476
497
 
477
- Plugin pages need `PUBLIC_URL`, as signing in does. A deployment installs its plugins by building them into its image from a list and a lockfile, with no install scripts, and names the directory ([deploy/README.md](../../deploy/README.md), "Plugins in the image", which says the rest: the list, the git installs, `CREATEROLE` for `storage:sql`, the bounds):
498
+ Plugin pages need `PUBLIC_URL`, as signing in does; `ctx.console.workspace().url` and `ctx.endpoints.url` read it too. The preview console (`pnpm dev:plugins`) answers as the console's own version, so that the real `console` command signs in to it (`console login --url <its address>`) and runs a plugin's commands as an agent would, and serves the bundles uncached. A deployment installs its plugins by building them into its image from a list and a lockfile, with no install scripts, and names the directory ([deploy/README.md](../../deploy/README.md), "Plugins in the image", which says the rest: the list, the git installs, `CREATEROLE` for `storage:sql`, the bounds):
478
499
 
479
500
  ```dockerfile
480
501
  FROM node:24-bookworm-slim AS plugins