@skillcdn/console 0.4.4 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/api.d.ts +3 -3
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +3 -3
- package/dist/api.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +162 -15
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/format.d.ts +16 -3
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +68 -19
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +26 -5
- package/dist/cli/help.js.map +1 -1
- package/dist/client.d.ts +28 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +12 -2
- package/dist/client.js.map +1 -1
- package/dist/compat.js +1 -1
- package/dist/components/board.d.ts.map +1 -1
- package/dist/components/board.js +46 -15
- package/dist/components/board.js.map +1 -1
- package/dist/components/decision-form.d.ts +0 -3
- package/dist/components/decision-form.d.ts.map +1 -1
- package/dist/components/decision-form.js +3 -15
- package/dist/components/decision-form.js.map +1 -1
- package/dist/components/event-feed.d.ts.map +1 -1
- package/dist/components/event-feed.js +30 -10
- package/dist/components/event-feed.js.map +1 -1
- package/dist/components/my-decisions.d.ts +20 -3
- package/dist/components/my-decisions.d.ts.map +1 -1
- package/dist/components/my-decisions.js +14 -1
- package/dist/components/my-decisions.js.map +1 -1
- package/dist/components/people.d.ts +43 -1
- package/dist/components/people.d.ts.map +1 -1
- package/dist/components/people.js +117 -9
- package/dist/components/people.js.map +1 -1
- package/dist/components/picker.d.ts +8 -0
- package/dist/components/picker.d.ts.map +1 -1
- package/dist/components/picker.js +2 -2
- package/dist/components/picker.js.map +1 -1
- package/dist/components/plugin-frame.d.ts +0 -1
- package/dist/components/plugin-frame.d.ts.map +1 -1
- package/dist/components/plugin-frame.js +22 -1
- package/dist/components/plugin-frame.js.map +1 -1
- package/dist/components/plugin-settings.d.ts +5 -1
- package/dist/components/plugin-settings.d.ts.map +1 -1
- package/dist/components/plugin-settings.js +143 -30
- package/dist/components/plugin-settings.js.map +1 -1
- package/dist/components/projects.d.ts.map +1 -1
- package/dist/components/projects.js +3 -1
- package/dist/components/projects.js.map +1 -1
- package/dist/components/searches.d.ts +11 -0
- package/dist/components/searches.d.ts.map +1 -0
- package/dist/components/searches.js +30 -0
- package/dist/components/searches.js.map +1 -0
- package/dist/components/task-form.d.ts.map +1 -1
- package/dist/components/task-form.js +6 -5
- package/dist/components/task-form.js.map +1 -1
- package/dist/components/ui.d.ts +16 -0
- package/dist/components/ui.d.ts.map +1 -1
- package/dist/components/ui.js +10 -0
- package/dist/components/ui.js.map +1 -1
- package/dist/console.css +362 -67
- package/dist/console.d.ts +2 -1
- package/dist/console.d.ts.map +1 -1
- package/dist/console.js +90 -14
- package/dist/console.js.map +1 -1
- package/dist/data.d.ts +14 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +36 -1
- package/dist/data.js.map +1 -1
- package/dist/i18n/en.d.ts +89 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +87 -2
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/ko.d.ts.map +1 -1
- package/dist/i18n/ko.js +87 -3
- package/dist/i18n/ko.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +6 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +6 -0
- package/dist/limits.js.map +1 -1
- package/dist/plugin/args.d.ts +6 -1
- package/dist/plugin/args.d.ts.map +1 -1
- package/dist/plugin/args.js +16 -11
- package/dist/plugin/args.js.map +1 -1
- package/dist/plugin/bridge.d.ts +20 -1
- package/dist/plugin/bridge.d.ts.map +1 -1
- package/dist/plugin/bridge.js +28 -0
- package/dist/plugin/bridge.js.map +1 -1
- package/dist/plugin/capabilities.d.ts +1 -1
- package/dist/plugin/capabilities.d.ts.map +1 -1
- package/dist/plugin/capabilities.js +10 -0
- package/dist/plugin/capabilities.js.map +1 -1
- package/dist/plugin/context.d.ts +158 -13
- package/dist/plugin/context.d.ts.map +1 -1
- package/dist/plugin/define.d.ts +13 -1
- package/dist/plugin/define.d.ts.map +1 -1
- package/dist/plugin/define.js +17 -1
- package/dist/plugin/define.js.map +1 -1
- package/dist/plugin/limits.d.ts +4 -0
- package/dist/plugin/limits.d.ts.map +1 -1
- package/dist/plugin/limits.js +4 -0
- package/dist/plugin/limits.js.map +1 -1
- package/dist/plugin/manifest.d.ts +15 -0
- package/dist/plugin/manifest.d.ts.map +1 -1
- package/dist/plugin/manifest.js +61 -4
- package/dist/plugin/manifest.js.map +1 -1
- package/dist/plugin/rest-schemas.d.ts +44 -0
- package/dist/plugin/rest-schemas.d.ts.map +1 -1
- package/dist/plugin/rest-schemas.js +55 -3
- package/dist/plugin/rest-schemas.js.map +1 -1
- package/dist/plugin/testing.d.ts +15 -1
- package/dist/plugin/testing.d.ts.map +1 -1
- package/dist/plugin/testing.js +40 -4
- package/dist/plugin/testing.js.map +1 -1
- package/dist/plugin/web.d.ts +6 -0
- package/dist/plugin/web.d.ts.map +1 -1
- package/dist/plugin/web.js +10 -1
- package/dist/plugin/web.js.map +1 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +1 -1
- package/dist/plugin.js.map +1 -1
- package/dist/router.d.ts +19 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +18 -0
- package/dist/router.js.map +1 -1
- package/dist/routes.d.ts +11 -2
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +14 -1
- package/dist/routes.js.map +1 -1
- package/dist/schemas.d.ts +367 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +62 -2
- package/dist/schemas.js.map +1 -1
- package/dist/vocabulary.d.ts +1 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +6 -0
- package/dist/vocabulary.js.map +1 -1
- package/docs/specs/cli.md +7 -4
- package/docs/specs/plugins.md +47 -39
- package/docs/specs/rest.md +22 -5
- package/package.json +1 -1
- package/skills/building-a-plugin/SKILL.md +8 -6
- package/skills/working-the-board/SKILL.md +1 -1
- package/src/api.ts +17 -0
- package/src/cli/cli.ts +200 -12
- package/src/cli/format.ts +82 -19
- package/src/cli/help.ts +30 -5
- package/src/client.ts +70 -0
- package/src/compat.ts +1 -1
- package/src/components/board.tsx +83 -42
- package/src/components/decision-form.tsx +13 -32
- package/src/components/event-feed.tsx +36 -9
- package/src/components/my-decisions.tsx +50 -5
- package/src/components/people.tsx +394 -37
- package/src/components/picker.tsx +11 -2
- package/src/components/plugin-frame.tsx +23 -0
- package/src/components/plugin-settings.tsx +346 -137
- package/src/components/projects.tsx +14 -14
- package/src/components/searches.ts +47 -0
- package/src/components/task-form.tsx +23 -28
- package/src/components/ui.tsx +36 -0
- package/src/console.tsx +229 -40
- package/src/data.ts +53 -1
- package/src/i18n/en.ts +98 -2
- package/src/i18n/ko.ts +95 -3
- package/src/index.ts +11 -0
- package/src/limits.ts +6 -0
- package/src/plugin/args.ts +16 -11
- package/src/plugin/bridge.ts +45 -0
- package/src/plugin/capabilities.ts +11 -0
- package/src/plugin/context.ts +170 -10
- package/src/plugin/define.ts +34 -0
- package/src/plugin/limits.ts +4 -0
- package/src/plugin/manifest.ts +74 -3
- package/src/plugin/rest-schemas.ts +62 -5
- package/src/plugin/testing.ts +61 -5
- package/src/plugin/web.ts +17 -0
- package/src/plugin.ts +1 -0
- package/src/router.ts +25 -0
- package/src/routes.ts +17 -2
- package/src/schemas.ts +81 -2
- package/src/styles/console.css +362 -67
- package/src/vocabulary.ts +6 -0
package/docs/specs/plugins.md
CHANGED
|
@@ -51,13 +51,14 @@
|
|
|
51
51
|
| `web` | path | the directory of the web bundle, with `index.js`; required where `pages` or `widgets` are declared |
|
|
52
52
|
| `skills` | path | the directory of the skills; `SKILLCDN.md` is then at the package root |
|
|
53
53
|
| `migrations` | path | the directory of SQL migrations; with `storage:sql` only |
|
|
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 |
|
|
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
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 } }` | role keys as `key`; up to 10; names and descriptions per language |
|
|
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)) |
|
|
57
57
|
| `defaultRole` | string | a role every member of the workspace holds without a grant; optional |
|
|
58
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 |
|
|
59
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 |
|
|
60
|
-
| `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 |
|
|
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)) |
|
|
61
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 |
|
|
62
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 |
|
|
63
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` |
|
|
@@ -150,17 +151,17 @@ export default definePlugin({
|
|
|
150
151
|
|
|
151
152
|
| Part | Shape | Notes |
|
|
152
153
|
|---|---|---|
|
|
153
|
-
| `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 |
|
|
154
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`) |
|
|
155
156
|
| `queries` | `defineQuery({ about, args, roles?, run })` | reads only; `run` returns JSON up to 4 MiB, or `{ file: { name, type, bytes } }` for an export |
|
|
156
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 |
|
|
157
|
-
| `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 |
|
|
158
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 |
|
|
159
160
|
| `upgrades` | `[{ to, about, run }]` | run in order by the `migrate` role from the installed `dataVersion` to the manifest's |
|
|
160
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 |
|
|
161
162
|
| `onEnable`, `onDisable` | `(ctx) => Promise<void>` | optional; run when an administrator enables or disables |
|
|
162
163
|
|
|
163
|
-
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.
|
|
164
165
|
|
|
165
166
|
## The context
|
|
166
167
|
|
|
@@ -168,27 +169,29 @@ Everything a plugin does to the console is a call on `ctx`, which is a message t
|
|
|
168
169
|
|
|
169
170
|
| Member | What it is | Needs |
|
|
170
171
|
|---|---|---|
|
|
171
|
-
| `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 |
|
|
172
|
-
| `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 |
|
|
173
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 |
|
|
174
175
|
| `ctx.sql` | `query(text, params)` and `transaction(async (tx) => ...)`, on the plugin's own role | `storage:sql` |
|
|
175
|
-
| `ctx.console.workspace()` | the workspace's name, languages, time zone, the day its week begins on (`weekStart`: `auto`, `monday` or `sunday`) and
|
|
176
|
-
| `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,
|
|
177
|
-
| `ctx.console.
|
|
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` |
|
|
178
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` |
|
|
179
|
-
| `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;
|
|
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` |
|
|
180
182
|
| `ctx.console.documents` | `list(project, folder)`, `read(project, path)`, `write(project, path, { title, body })` under `plugins/<key>/` | `board:read`; `board:documents` |
|
|
181
183
|
| `ctx.console.runs` | `list(project)`, `get(id)` | `board:read` |
|
|
182
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 |
|
|
183
|
-
| `ctx.fetch(url, init)` | an outbound request; `init` may carry `secret: { name, as }`, `as` one of `header:<name>`, `query:<name>` and `basic
|
|
184
|
-
| `ctx.secrets` | `has(name)`, `sign(name, payload, algorithm)
|
|
185
|
-
| `ctx.plugins.get(key)` | `{ version, api: { [name]: (input) => Promise<unknown> } }` of another plugin,
|
|
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 |
|
|
186
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 |
|
|
187
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 |
|
|
188
191
|
|
|
189
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.
|
|
190
193
|
|
|
191
|
-
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`.
|
|
192
195
|
|
|
193
196
|
## Capabilities
|
|
194
197
|
|
|
@@ -199,12 +202,14 @@ Declared in the manifest, consented to by an administrator when enabling, enforc
|
|
|
199
202
|
| (none) | its own records, versions, links and events; its settings; its log | its own data, spoiled; the versions restore it |
|
|
200
203
|
| `files` | files on its records, within `PLUGIN_FILE_BYTES_AT_MOST` | storage filled to the bound |
|
|
201
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 |
|
|
202
|
-
| `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.listed`, `person.unlisted`) to its hooks, with their content | the directory, and when people come and go |
|
|
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 |
|
|
203
208
|
| `projects:read` | the projects and members the caller may see | what the person sees |
|
|
204
209
|
| `projects:read:all` | every project and its members | the projects and who is in them |
|
|
205
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 |
|
|
206
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 |
|
|
207
|
-
| `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`) |
|
|
208
213
|
| `board:documents` | pages written under `plugins/<key>/` in its projects | pages that mislead, in its folder |
|
|
209
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 |
|
|
210
215
|
| `events:plugins` | hooks on the events other plugins emit | what they publish |
|
|
@@ -212,7 +217,7 @@ Declared in the manifest, consented to by an administrator when enabling, enforc
|
|
|
212
217
|
| `outbound:<host>` | `ctx.fetch` to that host, a public address, over https | what it may read, sent to that host; a cost there |
|
|
213
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 |
|
|
214
219
|
| `endpoints` | inbound requests under its path, a person's or a webhook's | input it accepts without verifying; the rate limit holds |
|
|
215
|
-
| `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 |
|
|
216
221
|
| `storage:sql` | a schema and a login role of its own | statements up to the timeout, within its schema |
|
|
217
222
|
|
|
218
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.
|
|
@@ -220,21 +225,22 @@ Declared in the manifest, consented to by an administrator when enabling, enforc
|
|
|
220
225
|
## Roles and grants
|
|
221
226
|
|
|
222
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.
|
|
223
|
-
- 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
|
|
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.
|
|
224
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.
|
|
225
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.
|
|
226
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.
|
|
227
233
|
|
|
228
234
|
## Settings
|
|
229
235
|
|
|
230
|
-
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. A `time` is `HH:MM`, read in the workspace's time zone, which the form says beside it. 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.
|
|
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.
|
|
231
237
|
|
|
232
238
|
## Records
|
|
233
239
|
|
|
234
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).
|
|
235
241
|
|
|
236
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.
|
|
237
|
-
- 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.
|
|
238
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.
|
|
239
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.
|
|
240
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.
|
|
@@ -247,24 +253,24 @@ With `storage:sql` consented to, enabling creates the schema `plugin_<key>` and
|
|
|
247
253
|
|
|
248
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.
|
|
249
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.
|
|
250
|
-
- 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,
|
|
251
|
-
- 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
|
|
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`).
|
|
252
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.
|
|
253
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.
|
|
254
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`.
|
|
255
261
|
|
|
256
262
|
## Events, hooks and jobs
|
|
257
263
|
|
|
258
|
-
- 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`.
|
|
259
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.
|
|
260
|
-
- 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.
|
|
261
|
-
- 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.
|
|
262
268
|
- `WORKER_IN_PROCESS` puts hooks and jobs in the `api` role's host.
|
|
263
269
|
|
|
264
270
|
## Between plugins
|
|
265
271
|
|
|
266
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`.
|
|
267
|
-
- `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.
|
|
268
274
|
- `events:plugins` is the loose coupling: a plugin emits, another listens; a payload is the emitter's to define and document.
|
|
269
275
|
|
|
270
276
|
## Lifecycle
|
|
@@ -297,14 +303,14 @@ Under `/api/v1/plugins`. Who may call: *anyone* signed in or on a token, *roles*
|
|
|
297
303
|
| Route | Who | What |
|
|
298
304
|
|---|---|---|
|
|
299
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 |
|
|
300
|
-
| `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
|
|
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 |
|
|
301
307
|
| `POST .../enable` | admin | `{ consent: [...], board: { create: { key, name, description } } \| { project: "<key>" } \| null, projects: "all" \| [...], settings: {...} }` |
|
|
302
308
|
| `POST .../disable` | admin | |
|
|
303
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` |
|
|
304
310
|
| `PATCH .../settings` | admin | `{ values }`, without secrets |
|
|
305
311
|
| `PUT .../secrets/<name>`, `DELETE .../secrets/<name>` | admin | `{ value }`; never read |
|
|
306
|
-
| `GET .../grants`, `POST .../grants`, `DELETE .../grants/<id>` | admin | `{ role, person?: id, project?: key, everyone?: true }` |
|
|
307
|
-
| `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 |
|
|
308
314
|
| `POST .../commands/<name>` | roles | `{ args, project?, run? }`, the arguments as JSON by name, for the pages; the same answer |
|
|
309
315
|
| `GET .../queries/<name>?args=<json>&project=` | roles | the query's JSON, or its file as bytes with `content-disposition` |
|
|
310
316
|
| `GET .../records/<collection>?where=&order=&limit=&after=&q=&project=` | roles | the records the caller may read |
|
|
@@ -330,9 +336,9 @@ Under `/api/v1/plugins`. Who may call: *anyone* signed in or on a token, *roles*
|
|
|
330
336
|
| Command | Asks | Says |
|
|
331
337
|
|---|---|---|
|
|
332
338
|
| `plugins` | `GET /api/v1/plugins` | a line per plugin: key, name, version, state, the caller's roles |
|
|
333
|
-
| `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 |
|
|
334
|
-
| `plugin <key> help [<command>]` | the same | one command whole, in the form of `console help <command>`: the forms,
|
|
335
|
-
| `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
|
|
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 |
|
|
336
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 |
|
|
337
343
|
| `plugin <key> skills [--search <words>]`, `plugin <key> skill <path>` | `GET .../skills...` | as `console skills` and `console skill` |
|
|
338
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 |
|
|
@@ -358,7 +364,7 @@ The rules an agent reads carry the plugins: `GET /api/v1/rules` and `GET /api/v1
|
|
|
358
364
|
</html>
|
|
359
365
|
```
|
|
360
366
|
|
|
361
|
-
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`, `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.
|
|
362
368
|
|
|
363
369
|
### The bridge
|
|
364
370
|
|
|
@@ -379,11 +385,12 @@ The console's page and the frame speak over `postMessage`, every message `{ v: 1
|
|
|
379
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 |
|
|
380
386
|
| the frame | `title` | `text`, for the document's title |
|
|
381
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 |
|
|
382
389
|
| the frame | `subscribe` | `kinds`, to receive `event` |
|
|
383
390
|
|
|
384
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.
|
|
385
392
|
|
|
386
|
-
`@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, 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.
|
|
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.
|
|
387
394
|
|
|
388
395
|
### Tabs, sub-tabs and addresses
|
|
389
396
|
|
|
@@ -417,7 +424,7 @@ The install path is the first line (a release of the deployment, from a lockfile
|
|
|
417
424
|
|
|
418
425
|
| | Held by |
|
|
419
426
|
|---|---|
|
|
420
|
-
| 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 |
|
|
421
428
|
| reach the database | no connection in the host; `ctx.sql` on the plugin's own role |
|
|
422
429
|
| read a file outside its own directory, or write any file | the permission model |
|
|
423
430
|
| start a process, load an addon, open a socket | the permission model, the module hook, the removed globals |
|
|
@@ -446,6 +453,7 @@ The install path is the first line (a release of the deployment, from a lockfile
|
|
|
446
453
|
| a plugin's memory | `PLUGIN_MEMORY_MB_AT_MOST`, 256 unless set |
|
|
447
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 |
|
|
448
455
|
| a plugin's own events | 100,000 kept, 90 days |
|
|
456
|
+
| a collection's retention | 1 to 3650 days; swept hourly |
|
|
449
457
|
| outbound requests; an answer; its time | 60 a minute per plugin; 10 MiB; 10 s |
|
|
450
458
|
| webhook requests | 600 a minute per plugin |
|
|
451
459
|
| `ctx.log` | 100 lines a minute |
|
|
@@ -475,7 +483,7 @@ The install path is the first line (a release of the deployment, from a lockfile
|
|
|
475
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.
|
|
476
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.
|
|
477
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.
|
|
478
|
-
- `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.
|
|
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.
|
|
479
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.
|
|
480
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.
|
|
481
489
|
|
|
@@ -487,7 +495,7 @@ The install path is the first line (a release of the deployment, from a lockfile
|
|
|
487
495
|
| `PLUGIN_FILE_BYTES_AT_MOST` | unset | the bytes a plugin's files may hold together; unset, no bound |
|
|
488
496
|
| `PLUGIN_MEMORY_MB_AT_MOST` | 256 | the memory a plugin's thread may hold |
|
|
489
497
|
|
|
490
|
-
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):
|
|
491
499
|
|
|
492
500
|
```dockerfile
|
|
493
501
|
FROM node:24-bookworm-slim AS plugins
|
package/docs/specs/rest.md
CHANGED
|
@@ -99,16 +99,25 @@ The API is the board as its members see it: the people, the projects, and in eac
|
|
|
99
99
|
|
|
100
100
|
### `GET /api/v1/people?login=`
|
|
101
101
|
|
|
102
|
-
`{ "items": [person, ...], "listed": [listing, ...] }`: the people the asker may see, those in first and by login, and the logins an administrator let in that nobody has signed in with yet ([ADR-0022](../adr/0022-who-is-in-is-the-consoles-own-record-and-an-administrator-keeps-it.md)). Who sees whom is the workspace's setting, decided by the one policy of the server on every request ([ADR-0035](../adr/0035-who-may-see-whom-is-decided-by-one-policy-and-the-workspace-keeps-its-settings.md)): with `everyone`, everyone who has signed in; with `shared_projects`, a member sees the active people who are in a project they are in, themselves, and the administrators, while an administrator sees everyone whatever the setting; a project open to the workspace has everyone in it, so a member of one sees everyone. The logins let in (`listed`) and the organization's reading (`organization`) go only to whom the policy shows everyone, `[]` and `null` otherwise. With `login`, one person by their exact login, compared without regard to case, or none: whom the asker sees, and, for an owner of a project, anyone active of the workspace, so that an owner lists someone the list does not show, learning that the login is one of the workspace and nothing more; `listed` is `[]` and `organization` `null` then; an empty or overlong login is `request.invalid`. A person carries their `role` in the workspace (`admin`, who configures it, or `member`, who works in the projects they are in), their `status` (`active`, or `deactivated`: kept with everything they did, refused on every request, and offered to no picker), their `source` (what let them in at their last sign-in: `organization`, the git host's organization the deployment names; `domain`, the Workspace domain it names; or `listed`, an administrator's listing), `deactivatedAt` (or `null`) and `managerId`, the id of the person they report to, the reporting line of the directory an administrator keeps, or `null` for nobody (answered from 0.4.4 and read as optional): what a plugin that reads people takes as the default where it needs someone's manager, the approver of their leave, say, in place of a line of its own. A person also carries `language`, the language they read in: the one they chose, else the one the pages first saw them in, or `null` until then (answered from 0.4.4 and read as optional): what a plugin or an agent writes for them in ([ADR-0065](../adr/0065-a-plugin-writes-one-text-and-a-persons-language-is-kept-from-the-first-time-the-pages-see-them.md)). A listing carries its `id`, the `host` (`gh`, `google`), the `login` as the administrator spelled it, and `createdAt`. `organization` is the organization at the git host whose members are in, where the deployment names one (`host`, `name`, and `syncedAt`, when the worker last read it whole, or `null` until it has), or `null`. What a picker of workers and of members is made of, the active people. A token reads it as its person does: what the command's `--worker`, `member` and `person` take a login from, through `login`.
|
|
102
|
+
`{ "items": [person, ...], "listed": [listing, ...] }`: the people the asker may see, those in first and by login, and the logins an administrator let in that nobody has signed in with yet ([ADR-0022](../adr/0022-who-is-in-is-the-consoles-own-record-and-an-administrator-keeps-it.md)). Who sees whom is the workspace's setting, decided by the one policy of the server on every request ([ADR-0035](../adr/0035-who-may-see-whom-is-decided-by-one-policy-and-the-workspace-keeps-its-settings.md)): with `everyone`, everyone who has signed in; with `shared_projects`, a member sees the active people who are in a project they are in, themselves, and the administrators, while an administrator sees everyone whatever the setting; a project open to the workspace has everyone in it, so a member of one sees everyone. The logins let in (`listed`) and the organization's reading (`organization`) go only to whom the policy shows everyone, `[]` and `null` otherwise. With `login`, one person by their exact login, compared without regard to case, or none: whom the asker sees, and, for an owner of a project, anyone active of the workspace, so that an owner lists someone the list does not show, learning that the login is one of the workspace and nothing more; `listed` is `[]` and `organization` `null` then; an empty or overlong login is `request.invalid`. A person carries their `role` in the workspace (`admin`, who configures it, or `member`, who works in the projects they are in), their `status` (`active`, or `deactivated`: kept with everything they did, refused on every request, and offered to no picker), their `source` (what let them in at their last sign-in: `organization`, the git host's organization the deployment names; `domain`, the Workspace domain it names; or `listed`, an administrator's listing), `deactivatedAt` (or `null`) and `managerId`, the id of the person they report to, the reporting line of the directory an administrator keeps, or `null` for nobody (answered from 0.4.4 and read as optional): what a plugin that reads people takes as the default where it needs someone's manager, the approver of their leave, say, in place of a line of its own. A person also carries `language`, the language they read in: the one they chose, else the one the pages first saw them in, or `null` until then (answered from 0.4.4 and read as optional): what a plugin or an agent writes for them in ([ADR-0065](../adr/0065-a-plugin-writes-one-text-and-a-persons-language-is-kept-from-the-first-time-the-pages-see-them.md)). To an administrator, and to each person for themselves, a person carries `email`, their work email, lowercase, or `null` until known ([ADR-0069](../adr/0069-the-directory-keeps-a-work-email-read-by-an-administrator-the-person-and-a-plugin-allowed-to.md)): the address they sign in with at Google, kept at every sign-in, else what an administrator wrote; everyone else reads a person without it, here and wherever a person is named (answered from 0.4.5 and read as optional). A listing carries its `id`, the `host` (`gh`, `google`), the `login` as the administrator spelled it, and `createdAt`. `organization` is the organization at the git host whose members are in, where the deployment names one (`host`, `name`, and `syncedAt`, when the worker last read it whole, or `null` until it has), or `null`. What a picker of workers and of members is made of, the active people. A token reads it as its person does: what the command's `--worker`, `member` and `person` take a login from, through `login`.
|
|
103
103
|
|
|
104
104
|
### `PATCH /api/v1/people/<id>`
|
|
105
105
|
|
|
106
|
-
Takes `role`, `status`, `managerId`, one or more, and answers the person
|
|
106
|
+
Takes `role`, `status`, `managerId`, `email`, one or more, and answers the person, with their `email`. For an administrator, signed in or through their agent's token ([ADR-0021](../adr/0021-a-token-configures-as-its-person-does.md)). The board keeps at least one administrator in (`person.last_admin`). A change of role writes `person.role_changed` with `login` and `role`. `managerId` names whom the person reports to: another active person of the workspace who does not report to them, directly or through others, so that the line never loops, or `null` for nobody (`person.invalid_manager` otherwise); it writes `person.manager_changed` with `login` and `manager`, the manager's login or `null`, which reaches the hooks of a plugin that reads the directory as every people event does ([specs/plugins.md](plugins.md#capabilities)). `email` writes the person's work email, lowercase and one per workspace (`person.email_taken`), or `null` for none ([ADR-0069](../adr/0069-the-directory-keeps-a-work-email-read-by-an-administrator-the-person-and-a-plugin-allowed-to.md)); it writes `person.email_changed` with `login` and `set`, whether an address is set now, and never the address, so that the feed tells nobody what only an administrator, the person and a plugin with `people:read:email` read. A person who signs in at Google has theirs from the sign-in, written again at each: the address Google vouches for is theirs over an administrator's word, and whoever else of the workspace held it gives it up, their `email` becoming `null`, whether an administrator wrote it on them or they are the person of an account Google has since made again under another id, so that the sign-in never fails on it. Each address a sign-in changes, the other person's and the signing-in person's own, writes `person.email_changed` the same way, by nobody; a person's first sign-in writes `person.joined` alone. `status: "deactivated"` takes the person out: refused on every request from the next one, their agents' tokens with them, listed nowhere and offered to no picker, kept with everything they did; the listing they came in on goes with it, so that signing in again admits nobody. `status: "active"` takes them back in, and lists them again where a listing let them in. Each writes `person.deactivated` or `person.reactivated` with `login`; what is already so writes nothing. A person the organization let in is neither (`person.of_organization`): the organization says, on the schedule the worker reads it on.
|
|
107
107
|
|
|
108
108
|
### `POST /api/v1/listings` and `DELETE /api/v1/listings/<id>`
|
|
109
109
|
|
|
110
110
|
Who may sign in, by login ([ADR-0022](../adr/0022-who-is-in-is-the-consoles-own-record-and-an-administrator-keeps-it.md)): for an administrator, signed in or through their agent's token. `POST` takes `host` (one the deployment signs people in through; `listing.unknown_host` otherwise) and `login` (a login at the git host, or an address at Google, as the host spells one; trimmed, compared without regard to case), answers `201` with the listing, and writes `person.listed` with `login`. Whoever signs in with that account at that host is a person from then on, and the listing is theirs: bound to the account, so that a rename changes nothing and no other account can take the login. A login listed already, or one an active person signs in as, is `listing.exists`; a deactivated person's login may be listed, and they are back in, as listed, when they next sign in. `DELETE` takes a listing away that nobody has signed in with yet, answers `204` and writes `person.unlisted`; one somebody signed in with is `listing.not_found`, since it goes with the person's deactivation.
|
|
111
111
|
|
|
112
|
+
### `GET /api/v1/teams`
|
|
113
|
+
|
|
114
|
+
`{ "items": [team, ...] }`: 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)), by name, each `{ "id", "key", "name", "memberIds", "createdAt" }`, `memberIds` being the members the asker may see, under the same policy as the people. A team is a key, a name and its members, a person in any number of them: a unit to call people by, and to grant a plugin's role to; no head, no team above a team. The key ([ADR-0074](../adr/0074-a-team-is-called-by-a-key-that-never-changes-one-name-among-the-projects-keys.md)) is what the team is called by, in these paths, in a grant and to a plugin: a project key's shape (lowercase letters, digits and hyphens, at most `MAX_TEAM_KEY_LENGTH`, not `new`), one among the teams' keys and the projects' keys of the workspace, and it never changes. The people listing names each person's teams by key, as `teams`.
|
|
115
|
+
|
|
116
|
+
### `POST /api/v1/teams`, `PATCH /api/v1/teams/<key>`, `DELETE /api/v1/teams/<key>`, `POST /api/v1/teams/<key>/members` and `DELETE /api/v1/teams/<key>/members/<person id>`
|
|
117
|
+
|
|
118
|
+
For an administrator, signed in or through their agent's token. `POST` takes `{ "key", "name" }`: the key one that no team and no project of the workspace has (`team.key_taken`; a project is likewise refused a team's key, `project.key_taken`), the name one per workspace without regard to case (`team.name_taken`), up to `MAX_TEAMS` of them (`team.too_many`), and answers the team with `201`; `PATCH` takes `{ "name" }` and renames it, the key staying; `DELETE` removes it with its memberships and the plugin roles granted to it, and answers `204`. `POST .../members` takes `{ "personId" }`, an active person of the workspace (`team.invalid_member`), and answers the team; `DELETE .../members/<person id>` takes them out (`team.not_a_member` where they were not in) and answers the team. Each writes an event of the workspace's own, with the team's `key` beside its name in `team`: `team.created`, `team.renamed` (`team` the name now, `name` the name before), `team.removed`, `team.member_added` and `team.member_removed` (with `login`). A path whose key no team has is `team.not_found`.
|
|
119
|
+
|
|
120
|
+
|
|
112
121
|
### `GET /api/v1/projects`
|
|
113
122
|
|
|
114
123
|
`{ "items": [project, ...] }`: the projects the asker may see, by name. A project carries its `key` (what paths and the command say; lowercase letters, digits and hyphens; immutable), `name`, `description`, `visibility` (`workspace`: everyone of the workspace is a member; `private`: only those listed), `skillsAddress` (canonical, or `null` for the organization's), `packToken` (`{ setAt }` while the project holds a token for a private pack, never the token itself, or `null`, [ADR-0028](../adr/0028-the-console-reads-a-projects-pack-through-the-deployments-rest-api-with-a-pack-token-for-a-private-one.md)), its mark, `color` (one of `COLOR_NAMES`, or `null`) and `symbol` (up to `MAX_PROJECT_SYMBOL_LENGTH` characters with no whitespace, or `null`; where either is `null` the pages choose one from the key and the name, `projectColorOf` and `projectSymbolOf`, so that projects are told apart either way), `icon` (the project's own image for its mark, shown in place of the symbol: its `sha256` and its `contentType`, one of `PROJECT_ICON_TYPES`; or `null`), `role` (what the asker is in it: `owner`, who configures it, or `member`, who works in it), `openDecisions` (the decisions waiting in it) and `openRuns` (the runs open in it: begun and not ended, which says nothing of whether an agent is still at them, [ADR-0020](../adr/0020-the-console-does-not-claim-an-agent-is-at-work-a-run-says-when-it-was-last-heard-from-and-a-new-run-takes-over.md)), `createdAt` and `updatedAt`. A workspace administrator sees every project, as its owner; a member sees those open to the workspace, as a member, and those they are listed in, as what they are listed.
|
|
@@ -155,7 +164,15 @@ The rules of the board ([ADR-0023](../adr/0023-the-rules-of-a-board-are-pages-th
|
|
|
155
164
|
|
|
156
165
|
### `GET /api/v1/decisions?open=&raised=&limit=&offset=`
|
|
157
166
|
|
|
158
|
-
`{ "items": [{ "project": { "key", "name", "color", "symbol", "icon" }, "decision" }, ...], "more": boolean }`: the decisions addressed to the asker ([ADR-0042](../adr/0042-a-decision-is-addressed-to-a-person-its-raiser-unless-another-is-named.md)), alone or among others ([ADR-0057](../adr/0057-a-decision-may-be-addressed-to-several-people-and-the-first-answer-ends-it.md)),
|
|
167
|
+
`{ "items": [{ "project": { "key", "name", "color", "symbol", "icon" }, "decision" }, ...], "more": boolean }`: the decisions addressed to the asker ([ADR-0042](../adr/0042-a-decision-is-addressed-to-a-person-its-raiser-unless-another-is-named.md)), alone or among others ([ADR-0057](../adr/0057-a-decision-may-be-addressed-to-several-people-and-the-first-answer-ends-it.md)), wherever they are, in a project the asker may see 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); the project's key, name and mark come with one from elsewhere, and nothing else of it), a page at a time as the tasks are (`limit`, `offset`, `more`): the ones that wait first, the longest waiting first among them, then the answered, newest first, as the record; `open=true` keeps only those that wait, and `raised=true` answers instead the decisions the asker raised, through their agent or themselves, for someone else. Each decision comes with the project it belongs to, as the plate's things do, so that a page says and links it. What a person's own page of decisions reads, in both kinds, and `console decisions --mine` and `--raised`.
|
|
168
|
+
|
|
169
|
+
### `GET /api/v1/decisions/<id>?wait=` and `POST /api/v1/decisions/<id>/answer`
|
|
170
|
+
|
|
171
|
+
One decision by its id, wherever it is ([ADR-0068](../adr/0068-a-decision-reaches-whom-it-is-addressed-to-wherever-it-is-and-a-plugins-reaches-the-managers-above-them.md)): `{ "project": { "key", "name", "color", "symbol", "icon" }, "decision", "mayAnswer" }`, for whoever it reaches: anyone who may see its project; and, from outside the project, the people it is addressed to, the person who raised it, and, for a decision a plugin raised, whoever manages one of the people it is addressed to, directly or up the reporting line. `wait` holds the request as the project's route does. `mayAnswer` says whether the asker may answer it: anyone who may work in the project, those it is addressed to and the managers above them for a plugin's; the raiser reads alone. `POST .../answer` takes what the project's route takes and writes the answer for whoever `mayAnswer` is true for (`auth.forbidden` otherwise). A decision out of the asker's reach is `decision.not_found`, not told apart from one that is not there; nothing else of a project the asker may not see opens through it.
|
|
172
|
+
|
|
173
|
+
### `GET /api/v1/decisions/<id>/files/<file id>`
|
|
174
|
+
|
|
175
|
+
The bytes of a file put on a decision ([ADR-0055](../adr/0055-a-decision-carries-the-files-put-on-it-when-it-is-raised.md)) at the decision's own address, for whoever the decision reaches as `GET /api/v1/decisions/<id>` has it, in a project they may see 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)), with a session or a token: what the pages read a decision's attachments and the pictures its context names at, on the person's own decisions and at `/decisions/<id>`, where the decision may be of a project the person may not see. Answered as the project's address for files answers: the same `content-length`, `content-disposition`, types and policy. Only a file of that decision: another file of its project, a decision out of the asker's reach, one that is not there and an id that is no id are each `file.not_found`, told apart by nothing.
|
|
159
176
|
|
|
160
177
|
### `GET /api/v1/tokens` and `POST /api/v1/tokens`
|
|
161
178
|
|
|
@@ -239,7 +256,7 @@ Writing one, for whoever may work in the project, with a session or a token, is
|
|
|
239
256
|
|
|
240
257
|
### `POST /api/v1/projects/<key>/decisions`
|
|
241
258
|
|
|
242
|
-
Takes `question`, `options` (two to `MAX_OPTIONS`, each a `label` and optionally a `key`, a lowercase word spelled as a plugin's key is and given once, which the `decision.answered` event carries back as `optionKey`, so that a program reads the answer without comparing labels), and optionally `body`, and either `taskId` or `runId`; as JSON, or, with files put on the decision ([ADR-0055](../adr/0055-a-decision-carries-the-files-put-on-it-when-it-is-raised.md)), as a form (`multipart/form-data`) whose part `decision` says what the JSON would, with a part `file` for each file, each checked as a file handed in is, at most `MAX_FILES_PER_DECISION` of them and together at most `MAX_FILE_BYTES` (`request.too_large`); the whole form is read and found good before any byte is kept, and a picture among the files is shown where the body names it as ``, the rest after the body. `deciderId` names whom the decision is for ([ADR-0042](../adr/0042-a-decision-is-addressed-to-a-person-its-raiser-unless-another-is-named.md)): a person who may work in the project, as a worker of a task must (`decision.invalid_decider` otherwise); left out, the asker, which from a run is the person the agent works for. `deciderIds` names several in its place ([ADR-0057](../adr/0057-a-decision-may-be-addressed-to-several-people-and-the-first-answer-ends-it.md)), each once in the order given and each able to work in the project, up to `MAX_DECIDERS` (`decision.too_many_deciders` beyond it): every one of them finds the decision among their own, any of them may answer, and the first answer ends it. Anyone of the project may still answer. Options are numbered from `1`. From a run, which must be the asker's and open (`decision.invalid_run`), the decision is about the run's task, `taskId` is not looked at, and the run waits (`status: waiting`) until the answer. Answers `201` with the decision and writes a `decision.raised` event, with the `agent` when a run asked and, when files came, how many as `files`.
|
|
259
|
+
Takes `question`, `options` (two to `MAX_OPTIONS`, each a `label` and optionally a `key`, a lowercase word spelled as a plugin's key is and given once, which the `decision.answered` event carries back as `optionKey`, so that a program reads the answer without comparing labels), and optionally `body`, and either `taskId` or `runId`; as JSON, or, with files put on the decision ([ADR-0055](../adr/0055-a-decision-carries-the-files-put-on-it-when-it-is-raised.md)), as a form (`multipart/form-data`) whose part `decision` says what the JSON would, with a part `file` for each file, each checked as a file handed in is, at most `MAX_FILES_PER_DECISION` of them and together at most `MAX_FILE_BYTES` (`request.too_large`); the whole form is read and found good before any byte is kept, and a picture among the files is shown where the body names it as ``, the rest after the body. `deciderId` names whom the decision is for ([ADR-0042](../adr/0042-a-decision-is-addressed-to-a-person-its-raiser-unless-another-is-named.md)): a person who may work in the project, as a worker of a task must (`decision.invalid_decider` otherwise), or, where a plugin raises it, anyone active of the workspace ([ADR-0068](../adr/0068-a-decision-reaches-whom-it-is-addressed-to-wherever-it-is-and-a-plugins-reaches-the-managers-above-them.md)); left out, the asker, which from a run is the person the agent works for. `deciderIds` names several in its place ([ADR-0057](../adr/0057-a-decision-may-be-addressed-to-several-people-and-the-first-answer-ends-it.md)), each once in the order given and each able to work in the project, up to `MAX_DECIDERS` (`decision.too_many_deciders` beyond it): every one of them finds the decision among their own, any of them may answer, and the first answer ends it. Anyone of the project may still answer. Options are numbered from `1`. From a run, which must be the asker's and open (`decision.invalid_run`), the decision is about the run's task, `taskId` is not looked at, and the run waits (`status: waiting`) until the answer. Answers `201` with the decision and writes a `decision.raised` event, with the `agent` when a run asked and, when files came, how many as `files`.
|
|
243
260
|
|
|
244
261
|
### `GET .../decisions/<id>?wait=` and `POST .../decisions/<id>/answer`
|
|
245
262
|
|
|
@@ -275,7 +292,7 @@ How the work goes, and what was made. A report takes `body` (Markdown, `MAX_BODY
|
|
|
275
292
|
|
|
276
293
|
A file handed in, as the parts of a form (`multipart/form-data`): the part `file` carries the bytes, with the file's name and its media type; the part `label` is optional. The name is one line without a path (`MAX_FILE_NAME_LENGTH`), the file is not empty and at most `MAX_FILE_BYTES` (`request.too_large`), and a media type that is not one, or none, is `application/octet-stream`. Where the deployment bounds what one project's files hold together (`PROJECT_FILE_BYTES_AT_MOST`), a file that would take the project over it, counted with every file handed in, put in a comment or on a decision, or attached to a page, is `project.too_many_bytes` (`409`) and nothing of it is kept; a comment's files, a decision's files and a page's file are held to the same bound. The bytes are kept under their SHA-256, so the same file handed in twice is kept once; the artifact on the run is `kind: "file"` with `file` filled in and `url` null. Answers `201` with the run and tells the board (`run.handed_in`, with the label or the file's name). The same rules as for a link: the run's person only, while the run is open, and only so many. A picture among the files is shown on the task's page small, and large when pressed, and a report that names it as `` shows it where the words are.
|
|
277
294
|
|
|
278
|
-
The bytes of any file of the project, handed in by a run, put in a comment or on a decision, or attached to a page, are read back at `/api/v1/projects/<key>/files/<file id>`, the project's one address for files, by whoever may see the project, with a session or a token: `content-length` and `content-disposition` name the file, the few kinds a browser may show in place (pictures, plain text, Markdown, CSV, JSON) are answered with their type and `inline`, and every other kind as `application/octet-stream` and `attachment`, never sniffed (`x-content-type-options`) and under a policy that runs nothing (`sandbox`). An id that is a link's, or nothing, or another project's, is `file.not_found`.
|
|
295
|
+
The bytes of any file of the project, handed in by a run, put in a comment or on a decision, or attached to a page, are read back at `/api/v1/projects/<key>/files/<file id>`, the project's one address for files, by whoever may see the project, with a session or a token: `content-length` and `content-disposition` name the file, the few kinds a browser may show in place (pictures, plain text, Markdown, CSV, JSON) are answered with their type and `inline`, and every other kind as `application/octet-stream` and `attachment`, never sniffed (`x-content-type-options`) and under a policy that runs nothing (`sandbox`). An id that is a link's, or nothing, or another project's, is `file.not_found`. A file put on a decision is also read at the decision's own address, `GET /api/v1/decisions/<id>/files/<file id>`, by whoever the decision reaches, in the project or not.
|
|
279
296
|
|
|
280
297
|
### `POST .../runs/<id>/end`
|
|
281
298
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skillcdn/console",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "What a custom SkillCDN console is built from: the API client and schemas, the components, the composition of the default console, and the console command agents work the board with.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/skillcdn/console#readme",
|
|
@@ -71,25 +71,26 @@ Produces the **design**: one table, a row per thing the feature does, saying who
|
|
|
71
71
|
|---|---|
|
|
72
72
|
| An agent does it for a person, and something changes | A **command**: `defineCommand({ about, args, roles, run })`. Its `text` answer is for people, its `data` for agents (`--json`); it does one thing and does it again safely where it can. |
|
|
73
73
|
| An agent, or a page, looks something up | A **query**: `defineQuery(...)`, which writes nothing; a query may answer a file. |
|
|
74
|
-
| A person must approve, choose or confirm | A **decision** the command raises with `ctx.console.decisions.ask`, with options, addressed to the person a setting or the record names (`deciderId`), or to several of them (`deciderIds`, any of whom may answer), `about` the record it is for, its question, body and labels written once, in the language of whoever is to answer: `ctx.console.people.get(id)` says it as `language`, and where that is `null`, a person who has not opened the console yet, the plugin writes in the first of `ctx.console.workspace().languages` ([ADR-0065](/docs/adr/0065-a-plugin-writes-one-text-and-a-persons-language-is-kept-from-the-first-time-the-pages-see-them.md)); the command answers `waiting`, the agent waits as the console's own `ask` is waited for, and the plugin hears the answer in a hook on `console:decision.answered` and goes on. The plugin never answers one, and never computes what a person must choose. |
|
|
74
|
+
| A person must approve, choose or confirm | A **decision** the command raises with `ctx.console.decisions.ask`, with options, addressed to the person a setting or the record names (`deciderId`), or to several of them (`deciderIds`, any of whom may answer), anyone active of the workspace, in the board project or not: they find it among their decisions and answer it there, and whoever manages them up the reporting line may answer it too ([ADR-0068](/docs/adr/0068-a-decision-reaches-whom-it-is-addressed-to-wherever-it-is-and-a-plugins-reaches-the-managers-above-them.md)); a page sends a person to it with `frame.navigate("/decisions/<id>")`, which opens for them though the project does not; `about` the record it is for, its question, body and labels written once, in the language of whoever is to answer: `ctx.console.people.get(id)` says it as `language`, and where that is `null`, a person who has not opened the console yet, the plugin writes in the first of `ctx.console.workspace().languages` ([ADR-0065](/docs/adr/0065-a-plugin-writes-one-text-and-a-persons-language-is-kept-from-the-first-time-the-pages-see-them.md)); the command answers `waiting`, the agent waits as the console's own `ask` is waited for, and the plugin hears the answer in a hook on `console:decision.answered` and goes on. The plugin never answers one, and never computes what a person must choose. |
|
|
75
75
|
| Someone must do something later | A **task** on the board project with `ctx.console.tasks.create`, marked as the plugin's, worked by whoever takes it; its title and body, and a comment on it, one text each, written as a decision's words are, in the language of whoever it is for. |
|
|
76
|
-
| The console must tell the plugin something happened | A **hook**, `on["console:<kind>"]` (`events:console`): the plugin's board project, the projects it is on and the items it made are what it hears of. Delivery is at least once: a handler reads the record first and does nothing twice. |
|
|
76
|
+
| The console must tell the plugin something happened | A **hook**, `on["console:<kind>"]` (`events:console`): the plugin's board project, the projects it is on and the items it made are what it hears of. Delivery is at least once: a handler reads the record first and does nothing twice, and the event's `id` (one across both streams) with its `attempt` is what it keeps to tell a delivery repeated; a service that says to come back later is told to the console as `retryAfterMs` on the refusal, and the next try waits that long. A collection that grows with every event, deliveries, log lines, gets `retention: { days }` and `history: false`, so that it never reaches the quota. |
|
|
77
77
|
| Another plugin must tell it | A **hook** on `plugin:<key>:<kind>` (`events:plugins`), or a call on the other's `api` (`plugins:<key>`). |
|
|
78
78
|
| Time passes | A **job** (`jobs`), `every` or `at` in the workspace's time zone, never two at once; what it did goes into records and, where people should know, a task or a decision. |
|
|
79
79
|
| An outside service calls in | An **endpoint** (`endpoints`): a `person` endpoint for whoever holds a role, a `webhook` for nobody, verified with `ctx.secrets.verify` against a secret setting. |
|
|
80
|
-
| The plugin must call out | `ctx.fetch` to a host the manifest names (`outbound:<host>`), the
|
|
80
|
+
| The plugin must call out | `ctx.fetch` to a host the manifest names (`outbound:<host>`), with `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE` or `OPTIONS`, the secrets put in by the console from `secret` settings (`secret` or `secrets`, each `as` a header, `bearer`, a query parameter, `basic` or `body:<field>`), never read by the plugin, nor in an answer that sends them back; what an answer gives back that is secret, the tokens of an exchange, sealed by the console into `secret` settings with `seal` before the plugin reads the answer, with their expiry, the answer's `sealed` saying what became of each (a token endpoint is asked for JSON, since the body of an answer that is no JSON is withheld), and sealed again by a job that reads `ctx.secrets.status(name)`; a callback the service is to call is `ctx.endpoints.url(name)`, the full address of one of the plugin's endpoints, and a link back to the console starts from `ctx.console.workspace().url`. |
|
|
81
|
+
| It acts for a person who did something elsewhere, or reads another plugin as itself | `people:act`, then `ctx.plugins.get(key).for(personId)` for the other plugin's api as the person, or `ctx.console.decisions.answer(id, answer, { for })` for a decision the person answered elsewhere, one on their own plate (addressed to them, or a plugin's addressed to someone they manage): the person's standing as a member decides, never their administration, and the record is theirs through the plugin. A role of another plugin, asked in the manifest's `grants` with why, is held as itself from a job or a hook, with `ctx.plugin.grants` saying what was granted ([ADR-0072](/docs/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)). |
|
|
81
82
|
| People look at it, or fill a form (a calendar starts its week on `frame.context.workspace.firstDay`, the day the workspace's setting says, and reads `timeZone` beside it; faces come from `frame.people.picture(id)`, since a frame loads no image from elsewhere) | A **page** (level `workspace` or `project`) or a **widget** on the front page, which declares the `sizes` it comes in, at least one of them no wider than four cells, which is all a phone has (`small` 2×2, `medium` 4×2, `large` 4×4, `wide` 8×2, `full` 8×4, square cells of the page's grid) and lays itself out for the one it has (`frame.context.size`, `frame.onSize`), since the person arranges the page ([ADR-0061](/docs/adr/0061-the-front-page-is-one-grid-of-tiles-a-person-arranges-by-hand.md)): it shows records and calls commands and queries through the bridge; the logic stays in the server module, so that the command line and the pages do the same thing. |
|
|
82
83
|
| An agent must know how | A **skill** in the package, one per kind of work, naming the commands as `console plugin <key> <command>`, with the rules of that work; `SKILLCDN.md` at the root. The agent note of the manifest is the one line that sends an agent to them. |
|
|
83
84
|
|
|
84
85
|
Then the data. Records, unless the feature needs what records do not give: joins across many collections, more rows than a plugin's records may hold, or figures beyond counting, summing, the least and the most; then `storage:sql`, a schema and a role of the plugin's own with migrations under `migrations/`, which needs the deployment's database user to hold `CREATEROLE` and is said in the hand-over. Records are the default: the console keeps them with versions, files, links, search and aggregates, exports and purges them, and shows them to people by role; a schema is the plugin's to keep.
|
|
85
86
|
|
|
86
|
-
Then the capabilities: the list the split needs, each read as the sentence the administrator will consent to (the specification's catalogue says what each opens and what a malicious plugin could do with it). A capability no row needs is dropped; `people:read:all`, `projects:read:all`, `storage:sql` and every `outbound:<host>` are asked for only where the design shows the row that needs them. Write the design: the table, the collections with their fields, the roles, the settings, the board (`needs`, the defaults), the menu group, the pages and the widgets, and the capabilities with their sentences; stop for the checkpoint.
|
|
87
|
+
Then the capabilities: the list the split needs, each read as the sentence the administrator will consent to (the specification's catalogue says what each opens and what a malicious plugin could do with it). A capability no row needs is dropped; `people:read:all`, `people:read:email` (the work email, to match a person to an account elsewhere), `projects:read:all`, `storage:sql` and every `outbound:<host>` are asked for only where the design shows the row that needs them. Write the design: the table, the collections with their fields, the roles, the settings, the board (`needs`, the defaults), the menu group, the pages and the widgets, and the capabilities with their sentences; stop for the checkpoint.
|
|
87
88
|
|
|
88
89
|
### Phase 2: Start from the template, then fill it
|
|
89
90
|
|
|
90
91
|
Produces the **plugin as source**. `console-plugin init <dir> --key <key> --name "<Name>"` writes the smallest complete plugin: the manifest, a server module with one collection, one command and one query, a web bundle with one page and one widget, one skill with its `SKILLCDN.md`, the tests, the build and a README that says where things are; `--console` names the version of `@skillcdn/console` it depends on, the command's own unless said, and for a plugin written for a deployed console, the version that console runs (`console whoami` says it), and the manifest's `console` is written from it, the line of that version (`>=0.4.3 <0.5.0`): the consoles the plugin runs on, which the console checks when it reads the plugin and `console-plugin check` holds the dependency to; narrow it to the patch that brought what the plugin depends on, and widen it to the next line once the plugin is built against it; `--package <name>` the package's name where it is not `console-plugin-<key>`, a scoped one (`@<scope>/console-plugin-<key>`) for a repository of several plugins, published as public; `--repository <url>` with `--directory <path>` where the plugin lives in a repository, which fill `repository`, `homepage` and `bugs` and the manifest's `store`; and `--languages en,ko` the languages of its words, `en` first, which gives every language map and both manifests' translations the others as English words to translate, of which `console-plugin check` warns while they stay English. Then, in `<dir>`: `npm install`, `npm run build`, `npm test`, `npm run check`, all green before a line is changed, and `git init`.
|
|
91
92
|
|
|
92
|
-
Then the design goes in, file by file: the manifest `console-plugin.json` from the design, its `key`, names and descriptions per language, `agentNote`, `capabilities`, `roles` and `defaultRole`, `board`, `settings`, `menu`, `pages`, `widgets`, `dataVersion`; the server module `src/server/index.ts`, one `defineCollection` per collection, one `defineCommand` or `defineQuery` per row, `endpoints`, `on`, `jobs`, `api`, `onEnable` and `onDisable` where the design has them, exported by name for the tests and gathered in `definePlugin(...)` as the default export; the web bundle `src/web/index.ts` and `index.css`, where `connect()` gives the page the context (who looks, at what, in which language), `command`, `query`, `records`, `files`, `navigate
|
|
93
|
+
Then the design goes in, file by file: the manifest `console-plugin.json` from the design, its `key`, names and descriptions per language, `agentNote`, `capabilities`, `roles` (a role for managers is `follows: "reports"`, held by whoever someone reports to and granted by nobody) and `defaultRole`, `board`, `settings`, `menu`, `pages`, `widgets`, `dataVersion`; the server module `src/server/index.ts`, one `defineCollection` per collection, one `defineCommand` or `defineQuery` per row, `endpoints`, `on`, `jobs`, `api`, `onEnable` and `onDisable` where the design has them, exported by name for the tests and gathered in `definePlugin(...)` as the default export; the web bundle `src/web/index.ts` and `index.css`, where `connect()` gives the page the context (who looks, at what, in which language), `command`, `query`, `records`, `files`, `navigate` (a path of the console's), `open` (a link to the web or to mail, from the press itself, which the console's page opens in a new tab), `download`, `setTitle` and the events, every class prefixed `pl-<key>-`, the console's `sc-` classes and `--sc-*` tokens for the look, and every word a person wrote put in as text; the skills under `skills/<name>/SKILL.md`, one per kind of work, in the SkillCDN Format, and the README of the package. The server module imports only what the host allows and bundles everything it imports; it keeps no state between calls but in records, since its thread is started afresh after a fault. Where the design has `storage:sql`, `migrations/0001-<words>.sql` makes the tables, and `manifest.migrations` names the directory.
|
|
93
94
|
|
|
94
95
|
### Phase 3: Test it as the console runs it
|
|
95
96
|
|
|
@@ -118,7 +119,8 @@ The hand-over is a task on the deployment's board, written as [planning-the-boar
|
|
|
118
119
|
|
|
119
120
|
- People decide: a plugin raises decisions and waits; it never answers one, never acts as a person it is not, and never computes an approval. What it makes on a board carries its name and is reversible by people.
|
|
120
121
|
- The fewest capabilities: each one in the manifest has a row of the design that needs it, and the sentence an administrator reads for it is true of the code. `check` warns of one no definition uses; it is dropped.
|
|
121
|
-
- A secret is a `secret` setting the administrator sets, put into a request by the console or verified by it; the plugin never reads its value, logs it, keeps it in a record, or puts it in the repository, the tests or the chat.
|
|
122
|
+
- A secret is a `secret` setting the administrator sets or the console seals from an answer (`seal`), put into a request by the console within the `hosts` the setting names, or verified by it (`use: "verify"` for one that verifies alone); the plugin never reads its value, logs it, keeps it in a record, or puts it in the repository, the tests or the chat.
|
|
123
|
+
- A plugin acting for a person acts within what that person may do as a member and what the plugin was granted, both, and never answers a decision itself; it never calls its own api (`plugins:<its key>` is refused); a callee reads `ctx.caller.kind === "plugin"` and refuses where its rules need a person at the keyboard.
|
|
122
124
|
- Everything a person wrote is shown as text, in the pages and in a command's answer; the frame renders no HTML from a record, and its classes are prefixed `pl-<key>-`.
|
|
123
125
|
- The server module imports nothing the host refuses, reaches no network but `ctx.fetch`, no file but its own, and keeps no state between calls but in records; a hook does nothing twice.
|
|
124
126
|
- A page reaches the console through the bridge alone: a frame has no network and no WebRTC, and the console serves each script of the bundle with a line before it, so that a source map is a line out. A page that needs WebRTC asks for `outbound:webrtc`, optional where the plugin can go without it, whose reach an administrator reads before consenting: any address, from each person's browser, and no camera or microphone with it ([ADR-0066](/docs/adr/0066-webrtc-is-a-way-out-a-plugin-asks-for-and-how-a-frame-is-held-stays-in-one-place.md)).
|