initiative-plugin-sdk 7.1.0 → 8.0.0

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 CHANGED
@@ -16,7 +16,7 @@ holds its tokens, and builds its manifest.
16
16
  | `initiative-plugin-sdk/widget` | What a widget is handed, and the scenes it returns |
17
17
  | `initiative-plugin-sdk/parts` | The components a part's tree is built from: `Section`, `Stack`, `Field`, `Value`, `Text`, `Button` |
18
18
  | `initiative-plugin-sdk/jsx-runtime` | JSX for a part's tree, with no React |
19
- | `initiative-plugin-sdk/testing` | A declarative plug-in's requests and maps, run against recorded vendor answers |
19
+ | `initiative-plugin-sdk/testing` | A declarative plug-in's requests, maps and writes, run against recorded vendor answers |
20
20
  | `initiative-plugin-sdk/plugin-api.json` | The plug-in API contract: the OpenAPI document of every route a plug-in may call |
21
21
  | bin `initiative-plugin` | `init`, `build`, `pack`, `dev`, `validate`, `keygen`, `uid`, `schema` |
22
22
 
@@ -82,8 +82,9 @@ export default definePlugin({
82
82
  [`manifest.contract.json`](manifest.contract.json): `vendor`, `connections`
83
83
  (keyed by id), `webhooks`, `pages` (keyed by id),
84
84
  `dashboards`, `fields` (keyed by metadata key), `parts` and `actions` (keyed
85
- by id). The types are generated from the contract, so every term the
86
- contract declares has a type here.
85
+ by id), `settings` (keyed by setting key) and `help`. The types are
86
+ generated from the contract, so every term the contract declares has a type
87
+ here.
87
88
  - **Features follow from what is filled in.** A plug-in with widgets declares
88
89
  `widgets`, and so on.
89
90
 
@@ -170,12 +171,81 @@ hooks: {
170
171
  - `community_status` runs whenever the community's standing changes. The same
171
172
  value rides every call as `community_status`, so a missed hook is caught up
172
173
  on the next call.
173
- - `entitlement` carries the deployment's statement of what this community
174
- holds of the plug-in: `values`, whose keys are agreed between the plug-in
175
- and the deployment's issuer, and a `version` that rises with each
176
- statement. Statements can repeat or arrive out of order, so keep the
177
- highest version you have seen and ignore a lower one. A deployment with no
178
- issuer never sends one.
174
+ - `entitlement` carries billing's statement of what this community holds of
175
+ the plug-in: `values`, whose keys are agreed between the plug-in and
176
+ billing, and a `version` that rises with each statement. Statements can
177
+ repeat or arrive out of order, so keep the highest version you have seen and
178
+ ignore a lower one. A deployment with no billing never sends one. The SDK
179
+ verifies each statement before your hook sees it: see
180
+ [Entitlement statements](#entitlement-statements).
181
+
182
+ ### Entitlement statements
183
+
184
+ What a community holds of a plug-in (its allowance, the packs it bought) is
185
+ decided by billing, not by Initiative. So that a compromised Initiative cannot
186
+ grant itself an entitlement, billing signs each statement with a key of its
187
+ own, Initiative stores the statement and hands it on verbatim, and your
188
+ plug-in checks billing's signature. Initiative can withhold a statement; it
189
+ cannot mint or change one.
190
+
191
+ A statement is a compact JWS. Its header is
192
+ `{"alg": "EdDSA", "typ": "initiative-entitlement+jwt", "kid": …}`, and its
193
+ claims are `{"iss": "initiative-billing", "listing_uid", "community_ref",
194
+ "version", "values", "iat"}`. It has no expiry: it holds until a higher
195
+ version replaces it. The `entitlement` hook's body, and what
196
+ `client.entitlement()` reads, are both `{"statement": "<jws>"}`.
197
+
198
+ Billing's public key reaches your plug-in from the deployment, never from
199
+ Initiative: it is the runtime fact `entitlement_issuer_key`, an SPKI PEM in
200
+ `INITIATIVE_ENTITLEMENT_ISSUER_KEY_PEM`. `initiative-plugin build` adds that
201
+ fact to the runtime of every plug-in with an `entitlement` hook, so you need
202
+ not declare it, and refuses it under any other variable name.
203
+
204
+ The SDK checks every statement, on the hook and on `client.entitlement()`, in
205
+ this order, and refuses it at the first failure:
206
+
207
+ 1. the key is configured and is Ed25519; without one, every statement is
208
+ refused, and the plug-in logs why once;
209
+ 2. the header's `alg` is exactly `EdDSA` and its `typ` exactly
210
+ `initiative-entitlement+jwt`: the header never chooses the algorithm, so
211
+ `alg: none` or an HMAC trick goes nowhere;
212
+ 3. the Ed25519 signature over the JWS signing input;
213
+ 4. `iss` is `initiative-billing`, `listing_uid` is your plug-in's own `uid`,
214
+ `version` is a whole number of one or more, and `values` is an object.
215
+
216
+ Only then is your hook called, with `version` and `values` taken from the
217
+ verified claims and nothing else in the body. A refused statement is answered
218
+ `422 {"error": "statement-refused", "detail": "<reason>"}` and never reaches
219
+ your hook; `client.entitlement()` throws `EntitlementStatementError`, whose
220
+ `reason` is one of `no-key`, `bad-key`, `malformed`, `alg`, `typ`,
221
+ `signature`, `iss`, `listing` or `claims`.
222
+
223
+ **What it does not cover.** `community_ref` is billing's own pairwise
224
+ reference for the community, which your plug-in cannot relate to the
225
+ `installation` Initiative names. Initiative still vouches for which community a
226
+ statement is for, so a compromised Initiative could at worst copy a real,
227
+ signed statement from one community onto another. That gap is accepted.
228
+
229
+ **Alerts.** The SDK counts, for the process's life:
230
+
231
+ - every statement by outcome, `applied` (verified and handed on) or
232
+ `refused-<reason>`;
233
+ - every grant of an unlimited allowance (`monthly_allowance: null`) to a
234
+ community whose last statement the process saw did not grant one. The memory
235
+ is the process's own, so after a restart the first such statement counts
236
+ again.
237
+
238
+ `entitlementMetrics()` answers them as an object, and `entitlementMetricsText()`
239
+ in the Prometheus text format, to append to what your metrics endpoint serves:
240
+ `initiative_plugin_entitlement_statements_total{outcome="…"}` and
241
+ `initiative_plugin_entitlement_unlimited_grants_total`. Alert on any
242
+ `refused-*` and on any rise in unlimited grants. Neither ever refuses a
243
+ correctly signed statement.
244
+
245
+ **If you self-host.** A deployment that runs billing mints its statement
246
+ keypair and fills `INITIATIVE_ENTITLEMENT_ISSUER_KEY_PEM` for each plug-in that
247
+ declares the fact. A deployment with no billing leaves it unset: it sends no
248
+ statements, and any that did arrive would be refused.
179
249
 
180
250
  ### The vendor's own setup
181
251
 
@@ -214,6 +284,43 @@ vendor: {
214
284
  key must be a vendor field, each answer is written at most once, and
215
285
  `client_secret`, `pem` and `webhook_secret` go to `secret` fields.
216
286
 
287
+ ### A token minted on demand
288
+
289
+ A static connection's `token` has Initiative mint its access token rather than
290
+ store one: it signs a JWT with a vendor key, posts it to the vendor's
291
+ `exchange_url`, and caches the answer until shortly before it expires.
292
+ `exchange_body` is a JSON body posted with it, such as the narrower
293
+ permissions a token is asked for:
294
+
295
+ ```ts
296
+ token: {
297
+ type: "jwt_bearer",
298
+ exchange_url: "https://api.tracker.example/installations/{installation_id}/tokens",
299
+ exchange_body: { permissions: { issues: "write", contents: "read" } },
300
+ iss: "{vendor.app_id}",
301
+ key: "{vendor.private_key}",
302
+ },
303
+ ```
304
+
305
+ `exchange_url`, `iss` and every string in `exchange_body` may name a vendor
306
+ value as `{vendor.<key>}` and one of the connection's own fields as `{<key>}`;
307
+ `validateManifest` refuses a name that neither declares.
308
+
309
+ `exchange_body` may instead be an expression answering the body from
310
+ `connection` alone, the connection's own non-secret fields, managed ones
311
+ included, so the body can differ by connection: a vendor may refuse a token
312
+ request naming a permission the installation does not hold. A body that names a
313
+ vendor value stays an object. An answer that is not an object fails the token
314
+ request, so the connection cannot be used until the expression answers one.
315
+ `runExchangeBody` from `initiative-plugin-sdk/testing` answers the body
316
+ Initiative posts for a connection's `fields` and `vendor` values.
317
+
318
+ ```ts
319
+ // account_type is a managed field; only an organization's installation holds "members"
320
+ exchange_body:
321
+ '{"permissions": $merge([{"issues": "write"}, connection.account_type = "organization" ? {"members": "read"} : {}])}',
322
+ ```
323
+
217
324
  ### Pages
218
325
 
219
326
  A page is one of your plug-in's screens, framed by Initiative:
@@ -267,14 +374,15 @@ What a plug-in adds to the views and pages of Initiative's items: tasks,
267
374
  calendar events, queue items, counters, gallery images and posts
268
375
  (`ITEM_KINDS`). Initiative draws all of it with its own components, so no
269
376
  plug-in code runs in a reader's browser, and the community's managers place
270
- it; a plug-in never places itself. They are a container plug-in's: a declarative
271
- plug-in holds no installation token to keep metadata with, and no handler to
272
- run an action.
377
+ it; a plug-in never places itself. Any plug-in may declare them, container or
378
+ declarative.
273
379
 
274
380
  **Metadata** is what the plug-in keeps on an item or on its own install: values
275
381
  by key (a lowercase letter, then lowercase letters, digits, `_` and `.`), held
276
- by Initiative and written with the installation token (`client.metadata`,
277
- below). Initiative enforces its sizes: a value of at most 8192 bytes as JSON,
382
+ by Initiative. A container writes them with its installation token
383
+ (`client.metadata`, below); for a declarative plug-in, Initiative writes them
384
+ from the plug-in's expressions ([Declarative integrations](#declarative-integrations)).
385
+ Initiative enforces its sizes: a value of at most 8192 bytes as JSON,
278
386
  32 keys and 64 KiB on one item, and 256 keys and 1 MiB on the install. A
279
387
  string of at most 255 characters can be found by.
280
388
 
@@ -304,16 +412,64 @@ fields: {
304
412
  editor's Add picker shows; `tone` is a badge's tone when its value names none
305
413
  (`accent`, `positive`, `negative`, `warning`, `neutral`, `muted`).
306
414
 
307
- **An action** runs one of the plug-in's write endpoints on one item, from a
308
- part's button, or from the item's own menu with `menu: true`. Initiative calls
309
- the endpoint as the installation, with `viewer` and `subject` set, and the
310
- handler does the work, usually writing metadata. Initiative then answers the
311
- reader with the item's metadata as it stands. With `confirm`, Initiative asks
312
- first.
415
+ **A list field**, `list: true`, holds several values: its value is a list of
416
+ elements, each one value of the field's `kind` with a `ref` of 1 to 255
417
+ characters that tells it from the others. A `text`, `number`, `date`,
418
+ `datetime` or `checkbox` element is `{ ref, value }`; a `link`, `badge` or
419
+ `progress` element is the kind's own object with its `ref`, such as
420
+ `{ ref, url, text? }`. Initiative keeps each element as a value of its own,
421
+ held to the size of one value, and finds an element by its `ref`.
422
+
423
+ **States** say what state a value is in, in the plug-in's own words: up to 4
424
+ facets, each with up to 12 keys. A value names a key of each facet it is in as
425
+ `state`, and Initiative draws one badge per facet, labelled in the reader's
426
+ language. A list element, and a `link`, `badge` or `progress` value, carries
427
+ `state` beside its other properties; a `text`, `number`, `date`, `datetime` or
428
+ `checkbox` value that carries one is written as an element is, without the
429
+ `ref`: `{ value, state }`.
430
+
431
+ ```ts
432
+ fields: {
433
+ "tracker.issues": {
434
+ name: { en: "Issues" },
435
+ kind: "link",
436
+ list: true,
437
+ on: ["task"],
438
+ states: {
439
+ status: {
440
+ name: { en: "Status" },
441
+ values: { open: { label: { en: "Open" }, tone: "positive" }, closed: { label: { en: "Closed" }, tone: "muted" } },
442
+ },
443
+ },
444
+ actions: ["unlink"],
445
+ },
446
+ },
447
+ // an element: { ref: "12", url: "https://tracker.example/issues/12", text: "#12", state: { status: "open" } }
448
+ ```
449
+
450
+ A list field's `actions` are offered on each of its elements, and each is
451
+ offered on every item kind the field is. They are a declarative plug-in's: an
452
+ action run from an element reads the element's `ref` as `element`, and a
453
+ container's handler is told the item alone.
454
+
455
+ **An action** runs on one item, from a part's button, from the item's own menu
456
+ with `menu: true`, or from an element of a list field that offers it. A
457
+ container plug-in's action names one of its write endpoints: Initiative calls
458
+ it as the installation, with `viewer` and `subject` set, and the handler does
459
+ the work, usually writing metadata. A declarative plug-in's names an endpoint,
460
+ a `write`, or both, and Initiative does the work itself. Either way Initiative
461
+ then answers the reader with the item's metadata as it stands. With `confirm`,
462
+ Initiative asks first; with `admin_only: true`, only the community's admins may
463
+ run it.
464
+
465
+ `params` names the endpoint's params Initiative asks the reader for before it
466
+ runs the action, in a form drawn from their own declarations: each one's type,
467
+ label, and `options` or `options_from`. The endpoint is sent the reader's
468
+ answers and nothing else, so every param it requires is among them.
313
469
 
314
470
  ```ts
315
471
  actions: {
316
- "new-link": { name: { en: "New link" }, endpoint: "create-link", on: ["task"], menu: true },
472
+ "new-link": { name: { en: "New link" }, endpoint: "create-link", params: ["url"], on: ["task"], menu: true },
317
473
  },
318
474
  ```
319
475
 
@@ -331,8 +487,11 @@ plug-in's parts on one item.
331
487
  | `button` | `action`: an action's id; `variant?` (`primary`, `secondary`, `ghost`) | no |
332
488
 
333
489
  Every field and action a part names must be offered on each kind in the
334
- part's `on`. A tree is plain data, `{ type, props?, children? }`, or JSX through
335
- the SDK's runtime, which the build turns into the same data:
490
+ part's `on`. A `text` node's text may name `{item.id}`, the item's id, and
491
+ `{settings.<key>}`, one of the plug-in's [settings](#settings-and-help), which
492
+ Initiative fills in when it draws the part; `validateManifest` refuses any
493
+ other `{…}`. A tree is plain data, `{ type, props?, children? }`, or JSX
494
+ through the SDK's runtime, which the build turns into the same data:
336
495
 
337
496
  ```tsx
338
497
  /** @jsxImportSource initiative-plugin-sdk */
@@ -362,6 +521,37 @@ children join the node that holds it, and `null` and `false` children are left
362
521
  out. `key` is JSX's own and is not passed on, which is why a field is named by
363
522
  `field`.
364
523
 
524
+ ### Settings and help
525
+
526
+ **Settings** are values the community's admins see and may change on the
527
+ install, keyed by setting key, at most 16. A part's text names one as
528
+ `{settings.<key>}`, and a declarative plug-in's expressions read them as
529
+ `settings.<key>`.
530
+
531
+ ```ts
532
+ settings: {
533
+ prefix: { type: "string", label: { en: "Reference prefix" }, unique: true, assigned: "community_name" },
534
+ },
535
+ ```
536
+
537
+ A `unique` setting's value is held by no other community on the deployment for
538
+ this plug-in, and is a lowercase letter, then lowercase letters and digits, 2
539
+ to 16 characters in all (`^[a-z][a-z0-9]{1,15}$`), so a reference such as
540
+ `acme-7` reads one way. With `assigned: "community_name"`, Initiative gives it a
541
+ value when the community installs the plug-in: the letters and digits of the
542
+ community's name, in lowercase, with a short random suffix when another
543
+ community holds that value. An admin may rename it. The earlier values stay the
544
+ community's, and expressions read them as `aliases.<key>`, so a reference made
545
+ under one still resolves.
546
+
547
+ **Help** is markdown, by language tag, at most 2000 characters in each.
548
+ Initiative shows it behind a "?" on the headers of the plug-in's parts and on
549
+ its settings:
550
+
551
+ ```ts
552
+ help: { en: "Links issues to tasks. Name a task's reference, such as **acme-7**, in an issue's title to link it." },
553
+ ```
554
+
365
555
  ## 2. Widgets
366
556
 
367
557
  A widget is a module exporting `render`, typed from the endpoint it draws:
@@ -499,6 +689,11 @@ for (const { installation, active } of await initiative.installations()) {
499
689
  }
500
690
  ```
501
691
 
692
+ An `Initiative` made by hand reads `client.entitlement()` only when it is given
693
+ something to check statements with: `entitlements: new EntitlementStatements({
694
+ listingUid: plugin.uid })`, which reads billing's key from
695
+ `INITIATIVE_ENTITLEMENT_ISSUER_KEY_PEM`. The server's own client has one.
696
+
502
697
  - `asInstallation(installation, { initiative, scopes })` acts as the community,
503
698
  optionally narrowed to one initiative the plug-in is placed in and fewer scopes.
504
699
  - `asMember(installation, member, { purpose, initiative })` acts for one
@@ -527,7 +722,11 @@ for (const { installation, active } of await initiative.installations()) {
527
722
  path after `/c/{community}`) by hand, with the same scope check.
528
723
  - `client.callPlugin(publicId, endpointId, params)` calls another plug-in's public
529
724
  endpoint through Initiative. It needs `plugins:<publicId>` among the plug-in's
530
- scopes, granted by the community.
725
+ scopes, granted by the community. A plug-in that uses whichever plug-ins a
726
+ community lets it sets `callsPlugins: true` (the manifest's
727
+ `service.calls_plugins`) instead of naming each: a community that installs
728
+ another plug-in is asked there whether this one may use it, which grants the
729
+ scope.
531
730
  - `client.metadata`, the plug-in's metadata, always on the installation's
532
731
  token: `get(itemKind, ids)` answers each item's values by id;
533
732
  `set(itemKind, id, values)` writes some of one item's values, a null
@@ -540,7 +739,8 @@ for (const { installation, active } of await initiative.installations()) {
540
739
  token, `client.reportConfigStatus({ state, detail })`, and
541
740
  `client.emitEvent({ eventType, payload, initiativeId })`.
542
741
  - What the community holds of the plug-in: `client.entitlement()` reads the
543
- statement the `entitlement` hook delivers, or null when there is none, and
742
+ statement the `entitlement` hook delivers, verified the same way, or null
743
+ when there is none, and
544
744
  `client.reportUsage({ meter, period, quantity })` reports a meter's total
545
745
  for one `YYYY-MM` period. The total is the whole period's, never an
546
746
  increment, so repeating a report is harmless. Only meters the deployment
@@ -620,7 +820,11 @@ It holds nothing that raises a privilege. Resources, network rules, hostnames
620
820
  and every key and password stay the deployment's:
621
821
 
622
822
  - `facts` come from a fixed list: `app_url`, `app_origin`, `app_api_public`,
623
- `app_api_internal`, `page_origin` and `port`.
823
+ `app_api_internal`, `page_origin`, `port` and `entitlement_issuer_key`
824
+ (billing's statement key, added for you with an `entitlement` hook; see
825
+ [Entitlement statements](#entitlement-statements)). There is no other
826
+ channel to Initiative's own services: billing reaches a plug-in only through
827
+ Initiative.
624
828
  - `settings` are names the operator fills.
625
829
  - `secrets` are generated by the deployment and kept.
626
830
 
@@ -668,7 +872,9 @@ A plug-in that only calls a vendor's API and reshapes the answer needs no
668
872
  container. Name the `hosts` it calls and give each endpoint a `request` and a
669
873
  `map` in place of a handler: Initiative makes the call with the connection's
670
874
  credential, and maps the answer itself. The plug-in has no `service` block, no
671
- image and nothing to run, and its listing registers it as `declarative`.
875
+ image and nothing to run, and its listing registers it as `declarative`. It
876
+ may still keep values on Initiative's items, which its webhook events,
877
+ schedules and actions write and its fields and parts show.
672
878
 
673
879
  ```ts
674
880
  // src/plugin.ts
@@ -744,8 +950,10 @@ it("lists a repository's open issues", async () => {
744
950
  `connection` (the non-secret fields of the connection its request names),
745
951
  `connections` (on an endpoint, each connection its `requires` names that the
746
952
  call has, by id: `connections.workspace.owner`), `now`, and once a call has
747
- been answered `response` (`status`, `headers`, `body`). `build` parses
748
- every one and fails with its place in the manifest when one does not parse.
953
+ been answered `response` (`status`, `headers`, `body`). Every expression
954
+ also reads `settings`, the install's [settings](#settings-and-help) by key,
955
+ and `aliases`, each unique setting's earlier values. `build` parses every
956
+ one and fails with its place in the manifest when one does not parse.
749
957
  - **Each evaluation is bounded** by the contract's `CAPS.expressionTimeMs`,
750
958
  `CAPS.expressionDepth` and `CAPS.expressionOutputBytes`; past one, or on an
751
959
  error, the call answers `unavailable: mapping-failed`. `evaluate` from
@@ -754,10 +962,40 @@ it("lists a repository's open issues", async () => {
754
962
  request's `connection` names, as `auth` says: `Authorization: Bearer
755
963
  <token>` unless `auth: { header, prefix }` says otherwise. Every address
756
964
  must be https on one of `hosts`, exact or with one leading `*.` label. A
757
- request or step on an `interactive` connection names it in the endpoint's
758
- `requires` too, or `build` fails. A call made as a member carries a
759
- `static` connection's credential only when `requires` names it, and is
760
- refused otherwise.
965
+ request or step naming an `interactive` connection for every call names it
966
+ in the endpoint's `requires` too, and is on no endpoint whose `actors`
967
+ include `installation`, or `build` fails: a call made as the installation
968
+ has no member's credential. A call made as a member carries a `static`
969
+ connection's credential only when `requires` names it, and is refused
970
+ otherwise.
971
+ - **A connection per actor.** A write's request or step may name one
972
+ connection for each of the endpoint's `actors` in place of one for every
973
+ call, so a call made as the member carries the member's own credential and
974
+ one made as the installation the community's:
975
+
976
+ ```ts
977
+ export const comment = defineEndpoint({
978
+ direction: "write",
979
+ actors: ["member", "installation"],
980
+ params: {
981
+ number: { type: "int", label: { en: "Issue" }, required: true },
982
+ text: { type: "string", label: { en: "Comment" }, required: true },
983
+ },
984
+ returns: { id: "int" },
985
+ request: {
986
+ method: "POST",
987
+ url: '"https://api.tracker.example/issues/" & params.number & "/comments"',
988
+ body: '{"body": params.text}',
989
+ connection: { member: "account", installation: "workspace" },
990
+ },
991
+ map: '{"id": response.body.id}',
992
+ });
993
+ ```
994
+
995
+ `member` names an `interactive` connection and `installation` a `static`
996
+ one, for exactly the actors the endpoint declares. The member's needs no
997
+ `requires`: a call made as the member requires it. An expression's
998
+ `connection` holds the fields of the one the call carries.
761
999
  - **`steps`** in place of `request` makes up to three calls in order; each
762
1000
  reads the earlier ones as `steps.<name>`, and so does the map.
763
1001
  - **`paging`** on a request reads more pages before the map runs:
@@ -773,21 +1011,109 @@ it("lists a repository's open issues", async () => {
773
1011
  three `steps`, a map to `{values, account_label}`, and `refuse_when` with
774
1012
  its `code`, which read each step as `steps.<name>`) and a
775
1013
  `health` check run on an interval. **Webhooks** take `events`, each mapping
776
- a delivery to one of the plug-in's emissions, and `status` rows that set a
777
- connection's state; a delivery may be routed by a `header` instead of a
778
- body `path`.
779
- - **One plug-in is one kind.** A declarative plug-in has no handlers, hooks,
780
- schedules or pages, and asks for no scopes; a container plug-in uses none of
781
- these terms. `validateManifest` and `build` refuse a mix.
782
-
783
- `runEndpoint`, `runAfterConnect`, `runHealth` and `runWebhook` render each
784
- request as Initiative sends it, less the credential, answer it from the
785
- recorded `responses` in order, and return the requests beside the result or
1014
+ a delivery to one of the plug-in's emissions, changes to its values, or
1015
+ both, and `status` rows that set a connection's state; a delivery may be
1016
+ routed by a `header` instead of a body `path`.
1017
+ - **Scopes** are read scopes alone, at the manifest's top level, and
1018
+ `validateManifest` refuses any other: a declarative plug-in keeps its own
1019
+ values on items it can read, and does not call Initiative. Initiative keeps
1020
+ its values only on items those scopes read, such as tasks with
1021
+ `projects:read`.
1022
+ - **Values** are written from a `write` expression: Initiative makes the
1023
+ changes it answers, as the install. A **change** sets values by key,
1024
+ `{"set": {"<key>": <value>}}`, the shape of `client.metadata.set`: a null
1025
+ removes its key, and every value is held to the metadata caps. A list
1026
+ field's value is its whole list of elements; its elements change by their
1027
+ `ref` with `{"upsert": {"<key>": [<element>]}}`, which adds each element or
1028
+ merges it into the one with its `ref` (the properties it gives replace that
1029
+ element's, the rest stay, and its `state` merges by facet, so a write that
1030
+ knows one fact changes that fact alone), and `{"remove": {"<key>": ["<ref>"]}}`. A
1031
+ change gives at least one of `set`, `upsert` and `remove`, and changes each
1032
+ key with only one. A **targeted change** adds its `target`:
1033
+ `{"kind": "task", "id": 7}` for one item, `{"install": true}` for the
1034
+ install, or `{"holding": {"key": "<key>", "value": "<text>"}}` for every item
1035
+ on which the install holds that value under that key (a number or a boolean
1036
+ as JSON writes it, a list element by its `ref`). A target the install cannot
1037
+ read is skipped, one `write` answers at most 100 targeted changes
1038
+ (`CAPS.writeTargets`), and a `write` answering nothing changes nothing.
1039
+ - An **event** row's `write`, beside its `emit` and `map` or in their place,
1040
+ answers targeted changes from `headers`, `payload`, `connection`, `now`,
1041
+ `settings` and `aliases`. The first row whose `when` holds applies, its
1042
+ emission and its write both.
1043
+ - An **action** names an `endpoint`, a `write`, or both. The endpoint may be
1044
+ a read, fetching what the `write` keeps, or a write that changes something
1045
+ at the vendor. Initiative makes the endpoint's call with the `params` the
1046
+ reader answered, on the credential its `actors` say, its expressions also
1047
+ reading `item` (`{kind, id, title}`) and `element` (the `ref` of the list
1048
+ element it was run from, if any), then the one change `write` answers from
1049
+ `item`, `held` (the install's values on the item), `result` (the
1050
+ endpoint's mapped answer), `params`, `element`, `settings`, `aliases` and
1051
+ `now`.
1052
+ - A **schedule** makes a read as the installation on an interval: `endpoint`
1053
+ names a read endpoint whose `actors` include `installation`, `params`
1054
+ answers its params from `since` (when the schedule last succeeded, or
1055
+ null), `now`, `connection`, `settings` and `aliases`, and `write` answers
1056
+ targeted changes from `result`, `since`, `now`, `settings` and `aliases`.
1057
+ Its id is none of the plug-in's connections': Initiative keys connection
1058
+ health checks by connection id.
1059
+
1060
+ ```ts
1061
+ schedules: {
1062
+ sync: {
1063
+ every: "1h",
1064
+ endpoint: "updated-issues", // returns numbers: int list, states: string list
1065
+ params: '{"since": since}',
1066
+ write:
1067
+ '[$map(result.numbers, function($n, $i) { {"target": {"holding": {"key": "issue.number", "value": $string($n)}}, "set": {"issue.state": result.states[$i]}} })]',
1068
+ },
1069
+ },
1070
+ actions: {
1071
+ create: {
1072
+ name: { en: "Create an issue" },
1073
+ endpoint: "create-issue", // returns number: int, url: string
1074
+ on: ["task"],
1075
+ write: '{"set": {"issue.number": result.number, "issue.link": {"url": result.url, "text": "#" & result.number}}}',
1076
+ },
1077
+ link: {
1078
+ name: { en: "Link an issue" },
1079
+ endpoint: "link-issue", // takes number: int; returns number: int, url: string, state: string
1080
+ params: ["number"],
1081
+ on: ["task"],
1082
+ write: '{"upsert": {"issues.linked": [{"ref": $string(result.number), "url": result.url, "state": {"status": result.state}}]}}',
1083
+ },
1084
+ unlink: { name: { en: "Unlink" }, on: ["task"], write: '{"remove": {"issues.linked": [element]}}' }, // a list field's element action
1085
+ },
1086
+ ```
1087
+
1088
+ Wrap a list a `write` answers in `[ ]`: a JSONata path matching one item
1089
+ answers it alone, and a run refuses a lone change where a list belongs.
1090
+ - **One plug-in is one kind.** A declarative plug-in is a manifest Initiative
1091
+ runs, with no plug-in code: it has no handlers, hooks or pages. A container
1092
+ plug-in ships code, which Initiative calls, and uses none of the declarative
1093
+ terms. Any plug-in may keep values on items in Initiative: a container
1094
+ writes them with its installation token, and for a declarative plug-in
1095
+ Initiative writes them from the plug-in's expressions. `validateManifest`
1096
+ and `build` refuse a mix.
1097
+
1098
+ `runEndpoint`, `runAfterConnect`, `runHealth`, `runAction` and `runSchedule`
1099
+ render each request as Initiative sends it, less the credential, answer it from
1100
+ the recorded `responses` in order, and return the requests beside the result or
786
1101
  the code the run answered. `runEndpoint` takes each connection's fields by id
787
- in `connections`; it has no actor, so it does not refuse a call the way
788
- Initiative would for who is making it. A run fails when a request has no recorded answer
789
- left, when an answer is left over, or when the map's answer does not fit the
790
- declared returns.
1102
+ in `connections`, and the `actor` the call is made as, by default the
1103
+ endpoint's first: a request naming a connection per actor carries that
1104
+ actor's. It does not refuse a call the way Initiative would for who is making
1105
+ it. `runAction` takes the `item`, what the install `held` on it, the `params`
1106
+ the reader answered, the `element` it was run from and its endpoint's `actor`,
1107
+ and answers the endpoint's `result` and the `change`; `runSchedule` takes
1108
+ `since`, makes its read as the installation, and answers the endpoint's
1109
+ `result` and the `writes`;
1110
+ `runWebhook` answers a delivery's `event`, `writes` and `status`. Every runner
1111
+ but `runExchangeBody` takes the install's `settings` and `aliases`. A run fails
1112
+ when a request has no recorded answer left, when an answer is left over, when
1113
+ the map's answer does not fit the declared returns, or when a `write` answers
1114
+ what is not a change: one that changes a key twice, gives a list field anything
1115
+ but a list of elements, or upserts or removes elements of a field that is not a
1116
+ list.
791
1117
 
792
1118
  ## A private plug-in
793
1119
 
@@ -845,9 +1171,10 @@ npx initiative-plugin schema # the JSON Schema it checks again
845
1171
  `validateManifest` runs the bundled JSON Schema, then the checks a schema
846
1172
  cannot express: features against the blocks present, ids that must name
847
1173
  something the manifest declares, connection, vendor setup and schedule rules,
848
- what a part's nodes and an action name, a part's size and depth,
849
- and every term the contract does not declare (a deployment discards those
850
- without saying so). The deployment also enforces byte-size caps.
1174
+ what a part's nodes, a list field's element actions and an action's `params`
1175
+ name, the placeholders in a part's text, a part's size and depth, and every
1176
+ term the contract does not declare (a deployment discards those without saying
1177
+ so). The deployment also enforces byte-size caps.
851
1178
 
852
1179
  ## The contract
853
1180
 
@@ -929,7 +1256,14 @@ either the file's version or the client is stale.
929
1256
  | `initiatives:read` | The initiatives the plug-in is placed in. |
930
1257
  | `initiatives:moderate` | Acting as a moderator in an initiative it is placed in: everything there, within the plug-in's other scopes. Only on a token that asks for it (`level=moderator`) and is narrowed to that initiative. |
931
1258
  | `community:admin` | Acting with a community admin's standing across the community, within the plug-in's other scopes. Only on a token that asks for it (`level=community_admin`) and is not narrowed to an initiative. |
932
- | `plugins:<public id>` | Calling that plug-in's public endpoints through Initiative. One per plug-in. |
1259
+ | `plugins:<public id>` | Calling that plug-in's public endpoints through Initiative, and reading the values it shows on items the plug-in can read. One per plug-in. |
1260
+
1261
+ A container plug-in's `scopes` go in its manifest's `service` block. A
1262
+ declarative plug-in asks for read scopes alone, at the manifest's top level: it
1263
+ keeps its own values on items those scopes read, and does not call Initiative.
1264
+ A container plug-in with `callsPlugins: true` needs no `plugins:` scope in its
1265
+ manifest: a community that installs another plug-in is asked there whether
1266
+ this one may use it, and that grants `plugins:<public id>` for it.
933
1267
 
934
1268
  Writing implies reading. Within its scopes a plug-in still sees only what is open
935
1269
  to the initiative, shared with the plug-in, or created by it — unless a token asks
package/dist/build.d.ts CHANGED
@@ -52,7 +52,7 @@ export interface BuiltPlugin {
52
52
  listing?: ListingDeclaration;
53
53
  }
54
54
  /** What the listing writers read of a plug-in: its definition, or a {@link BuiltPlugin}'s. */
55
- export type ListedPlugin = Pick<AnyPlugin, "publicId" | "uid" | "name" | "scopes" | "hosts" | "hooks" | "listing">;
55
+ export type ListedPlugin = Pick<AnyPlugin, "publicId" | "uid" | "name" | "scopes" | "callsPlugins" | "hosts" | "hooks" | "listing">;
56
56
  /** What a definition builds: the plug-in and its manifest, or every problem that stops it. */
57
57
  export type Compiled = {
58
58
  plugin: ListedPlugin;