initiative-plugin-sdk 2.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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +656 -0
  3. package/dist/build.d.ts +52 -0
  4. package/dist/build.js +229 -0
  5. package/dist/build.js.map +1 -0
  6. package/dist/cli.d.ts +24 -0
  7. package/dist/cli.js +160 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/client.d.ts +288 -0
  10. package/dist/client.js +539 -0
  11. package/dist/client.js.map +1 -0
  12. package/dist/contract.d.ts +1247 -0
  13. package/dist/contract.js +167 -0
  14. package/dist/contract.js.map +1 -0
  15. package/dist/define.d.ts +336 -0
  16. package/dist/define.js +120 -0
  17. package/dist/define.js.map +1 -0
  18. package/dist/dev.d.ts +28 -0
  19. package/dist/dev.js +147 -0
  20. package/dist/dev.js.map +1 -0
  21. package/dist/expression.d.ts +28 -0
  22. package/dist/expression.js +67 -0
  23. package/dist/expression.js.map +1 -0
  24. package/dist/init.d.ts +14 -0
  25. package/dist/init.js +52 -0
  26. package/dist/init.js.map +1 -0
  27. package/dist/keys.d.ts +85 -0
  28. package/dist/keys.js +109 -0
  29. package/dist/keys.js.map +1 -0
  30. package/dist/manifest.d.ts +8 -0
  31. package/dist/manifest.js +8 -0
  32. package/dist/manifest.js.map +1 -0
  33. package/dist/pack.d.ts +35 -0
  34. package/dist/pack.js +84 -0
  35. package/dist/pack.js.map +1 -0
  36. package/dist/plugin-api.generated.d.ts +4263 -0
  37. package/dist/plugin-api.generated.js +1019 -0
  38. package/dist/plugin-api.generated.js.map +1 -0
  39. package/dist/server.d.ts +75 -0
  40. package/dist/server.js +408 -0
  41. package/dist/server.js.map +1 -0
  42. package/dist/testing.d.ts +116 -0
  43. package/dist/testing.js +386 -0
  44. package/dist/testing.js.map +1 -0
  45. package/dist/tokens.d.ts +161 -0
  46. package/dist/tokens.js +215 -0
  47. package/dist/tokens.js.map +1 -0
  48. package/dist/validate.d.ts +86 -0
  49. package/dist/validate.js +997 -0
  50. package/dist/validate.js.map +1 -0
  51. package/dist/widget.d.ts +235 -0
  52. package/dist/widget.js +26 -0
  53. package/dist/widget.js.map +1 -0
  54. package/examples/minimal/README.md +48 -0
  55. package/examples/minimal/assets/avatar.png +0 -0
  56. package/examples/minimal/package.json +22 -0
  57. package/examples/minimal/src/plugin.ts +77 -0
  58. package/examples/minimal/src/widgets/open-issues.ts +11 -0
  59. package/examples/minimal/test/fixtures/issues-statistics.json +9 -0
  60. package/examples/minimal/test/issue-counts.test.ts +48 -0
  61. package/examples/minimal/tsconfig.json +14 -0
  62. package/manifest.contract.json +1886 -0
  63. package/package.json +92 -0
  64. package/schemas/plugin-manifest.json +1747 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Morelitea
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,656 @@
1
+ # initiative-plugin-sdk
2
+
3
+ Build a plug-in for [Initiative](https://github.com/Morelitea/initiative): a
4
+ service that reads and writes a community's content, answers Initiative's
5
+ calls, and draws tiles on its dashboards.
6
+
7
+ A plug-in is its logic. You write one typed definition of its endpoints, hooks,
8
+ schedules, widgets and listing; the SDK serves it, verifies Initiative's calls,
9
+ holds its tokens, and builds its manifest.
10
+
11
+ | Import | Holds |
12
+ |---|---|
13
+ | `initiative-plugin-sdk/manifest` | `definePlugin`, `defineEndpoint`, the contract's types, `validateManifest` |
14
+ | `initiative-plugin-sdk/server` | `createPlugin`, `serve`, `EndpointError` |
15
+ | `initiative-plugin-sdk/client` | `Initiative` and the `Client` it gives, acting as the community or a member; plug-in keys |
16
+ | `initiative-plugin-sdk/widget` | What a widget is handed, and the scenes it returns |
17
+ | `initiative-plugin-sdk/testing` | A declarative plug-in's requests and maps, run against recorded vendor answers |
18
+ | bin `initiative-plugin` | `init`, `build`, `pack`, `dev`, `validate`, `keygen`, `uid`, `schema` |
19
+
20
+ Node 20 or later. Two runtime dependencies, `ajv` and `jsonata`; everything
21
+ cryptographic uses `node:crypto`. `initiative-plugin build` bundles widgets with
22
+ [esbuild](https://esbuild.github.io/), which you install beside the SDK:
23
+
24
+ ```sh
25
+ npm install initiative-plugin-sdk
26
+ npm install --save-dev esbuild
27
+ ```
28
+
29
+ ## 1. Define the plug-in
30
+
31
+ ```ts
32
+ // src/plugin.ts
33
+ import { definePlugin, defineEndpoint } from "initiative-plugin-sdk/manifest";
34
+
35
+ export const openTickets = defineEndpoint({
36
+ direction: "read",
37
+ label: { en: "Open tickets" },
38
+ params: { project: { type: "string", label: { en: "Project" } } },
39
+ returns: {
40
+ titles: { type: "string", list: true },
41
+ total: "int",
42
+ },
43
+ handler: async ({ params, installation, client }) => {
44
+ const tickets = await lookUp(installation, params.project);
45
+ return { result: { titles: tickets.map((ticket) => ticket.title), total: tickets.length } };
46
+ },
47
+ });
48
+
49
+ export default definePlugin({
50
+ publicId: "acme.tracker",
51
+ uid: "K7M2QX8N4TVB9C", // npx initiative-plugin uid, once
52
+ name: "Acme Tracker",
53
+ scopes: ["projects:read", "comments:write"],
54
+ endpoints: { "open-tickets": openTickets },
55
+ schedules: {
56
+ sweep: { every: "15m", run: async ({ since, installation, client }) => { /* … */ } },
57
+ },
58
+ widgets: {
59
+ "open-count": {
60
+ meta: { name: { en: "Open tickets" } },
61
+ endpoints: ["open-tickets"],
62
+ module: "src/widgets/open-count.ts",
63
+ sample_data: { "open-tickets": { total: 12 } },
64
+ },
65
+ },
66
+ });
67
+ ```
68
+
69
+ - **Types come from the definition.** An endpoint's `params` type what its
70
+ handler is handed, and its `returns` type what the handler answers with: a
71
+ return of the wrong type, or one the endpoint does not declare, does not
72
+ compile. A widget or a summary naming an endpoint that is not a declared read
73
+ does not compile either.
74
+ - **Endpoints are named by their key.** The manifest id is
75
+ `plugin.<publicId>.<key>`, and everywhere the definition refers to an endpoint
76
+ (a widget, a sample, a parameter's `options_from`, a bundled dashboard) it
77
+ uses the key.
78
+ - **Everything else is the contract's own shape**, from
79
+ [`manifest.contract.json`](manifest.contract.json): `vendor`, `connections`
80
+ (keyed by id), `webhooks`, `surfaces` (the contract's embeds, keyed by id),
81
+ `dashboards`. The types are generated from the contract, so every term the
82
+ contract declares has a type here.
83
+ - **Features follow from what is filled in.** A plug-in with widgets declares
84
+ `widgets`, and so on.
85
+
86
+ ### What a handler is handed
87
+
88
+ | | |
89
+ |---|---|
90
+ | `params` | What the caller sent, by the declared names. Initiative sends strings; an automation may send numbers and booleans as they are. |
91
+ | `installation` | The community, by the reference this plug-in's install knows it by. What your own rows key on. |
92
+ | `actor` | `{ kind: "installation" }`, or `{ kind: "member", member }` when another plug-in called as one of the community's members. |
93
+ | `caller` | The plug-in that made the call through Initiative, or null for Initiative's own. |
94
+ | `initiative` | The initiative the call is confined to, or null. |
95
+ | `connections` | Connection id → the handle Initiative gives a vendor token for. |
96
+ | `client` | Initiative, already acting as `actor` and narrowed to `initiative`. |
97
+ | `context` | What you gave `createPlugin` (below). |
98
+
99
+ A handler answers `{ result }`, and `actor` too when the call ran on another
100
+ credential than the call's own (a read that always uses the community's
101
+ installation, say). To refuse, throw `new EndpointError(status, code, detail)`;
102
+ anything else thrown is answered 500 and logged.
103
+
104
+ A `write` is reachable only through another plug-in, as one of the actors it
105
+ declares, and only when it is `public`. The SDK refuses anything else before
106
+ the handler runs.
107
+
108
+ ### Hooks
109
+
110
+ ```ts
111
+ hooks: {
112
+ after_connect: async ({ connection, actor, access_token, params, client }) => {
113
+ const login = await vendorLogin(access_token);
114
+ return { values: { login }, account_label: `@${login}` }; // or { refuse: true }
115
+ },
116
+ revoke: async ({ connection, access_token, refresh_token }) => { /* end the grant */ },
117
+ webhook: async ({ connection, headers, body, client }) => {
118
+ await client.emitEvent({ eventType: "plugin.acme.tracker.ticket-opened", payload: { number: 7 } });
119
+ },
120
+ },
121
+ ```
122
+
123
+ - `after_connect` runs once a connection's flow has exchanged its code, when
124
+ the flow sets `after_connect`.
125
+ - `revoke` runs when a connection whose flow says `revoke: "hook"` ends. A
126
+ flow can instead have Initiative end the grant itself: `rfc7009` posts to
127
+ `revoke_url`, and `github_grant` sends GitHub's "Delete an app authorization"
128
+ (`DELETE` with the client's credentials and the access token) to it.
129
+ - `webhook` runs once per community for each vendor delivery Initiative
130
+ received, checked and routed; `headers` are the vendor's `x-` headers,
131
+ lowercased, and `body` is exactly what the vendor sent.
132
+ - Each schedule's `run` is called once per community each interval, with
133
+ `since`: when it last succeeded there, or null the first time.
134
+
135
+ A hook that throws is answered 500: a connection is then not recorded, and a
136
+ delivery or a schedule is tried again.
137
+
138
+ ### The vendor's own setup
139
+
140
+ `vendor.fields` are the values an operator gives once per deployment for the
141
+ vendor's client. When the vendor can create that client itself, `vendor.setup`
142
+ says how, and Initiative runs it from the operator's browser and writes what
143
+ the vendor answers into those fields. One kind exists, `github_app_manifest`
144
+ (GitHub's app manifest flow):
145
+
146
+ ```ts
147
+ vendor: {
148
+ fields: [
149
+ { key: "app_id", type: "string", required: true, label: { en: "App id" } },
150
+ { key: "private_key", type: "secret", required: true, label: { en: "Private key" } },
151
+ // …
152
+ ],
153
+ setup: {
154
+ kind: "github_app_manifest",
155
+ app: {
156
+ name: "Acme Tracker",
157
+ url: "https://tracker.acme.example",
158
+ default_permissions: { issues: "write", metadata: "read" },
159
+ default_events: ["issues"],
160
+ },
161
+ values: { app_id: "id", private_key: "pem" /* , … */ },
162
+ },
163
+ },
164
+ ```
165
+
166
+ - `app` is what GitHub is asked to create: its name, its homepage (https),
167
+ whether any account may install it (`public`, default false), and its
168
+ permissions and events by GitHub's names. Initiative fills in the callback,
169
+ setup, webhook and redirect URLs itself, so `app` names none.
170
+ - `values` maps each of your vendor field keys to one field of GitHub's answer:
171
+ `id`, `slug`, `client_id`, `client_secret`, `pem` or `webhook_secret`. Each
172
+ key must be a vendor field, each answer is written at most once, and
173
+ `client_secret`, `pem` and `webhook_secret` go to `secret` fields.
174
+
175
+ ### Surfaces
176
+
177
+ A surface is one of your plug-in's pages, framed by Initiative:
178
+
179
+ ```ts
180
+ surfaces: {
181
+ board: {
182
+ path: "/board",
183
+ name: { en: "Board" },
184
+ scopes: ["initiative"],
185
+ handler: async ({ request, handoff }) => {
186
+ if (!handoff) return servePage(request); // the page's own files
187
+ return startSession(handoff.viewer, handoff.initiative);
188
+ },
189
+ },
190
+ },
191
+ ```
192
+
193
+ Initiative hands the frame a one-use handoff token. Send it from the page to
194
+ any path under the surface's as `Authorization: Bearer …`: the SDK verifies it
195
+ (type, signature, audience, surface, and that it was not used before) and hands the
196
+ handler `viewer`, `admin`, `initiative` and a `client` acting as the
197
+ installation, narrowed to that initiative.
198
+
199
+ ## 2. Widgets
200
+
201
+ A widget is a module exporting `render`, typed from the endpoint it draws:
202
+
203
+ ```ts
204
+ // src/widgets/open-count.ts
205
+ import type { Scene, WidgetData } from "initiative-plugin-sdk/widget";
206
+ import type { openTickets } from "../plugin.js";
207
+
208
+ export function render(data: WidgetData<typeof openTickets>): Scene {
209
+ return { v: 1, scene: { kind: "metric", value: data.values.total ?? 0, label: "Open" } };
210
+ }
211
+ ```
212
+
213
+ `data.rows` holds one entry per index across the endpoint's `list` returns,
214
+ and `data.values` its single-valued returns. `render` also receives the tile's
215
+ options and `{ locale, slots }`.
216
+
217
+ The build bundles each widget, with everything it imports, into the one script
218
+ Initiative runs in a sandbox with no network, DOM or timers, and checks the
219
+ contract's size cap. A widget imports nothing at run time.
220
+
221
+ ## 3. Build
222
+
223
+ ```sh
224
+ npx initiative-plugin build # writes manifest.json
225
+ npx initiative-plugin build --check # CI: fails if manifest.json is stale
226
+ ```
227
+
228
+ `build` reads `src/plugin.ts` (`--plugin <file>` for another), bundles the widgets,
229
+ checks the result with `validateManifest`, and writes `manifest.json`. Commit
230
+ it and ship it with the plug-in: the server serves it, and refuses to start if it
231
+ no longer matches the definition.
232
+
233
+ ## 4. Serve
234
+
235
+ ```ts
236
+ // src/main.ts
237
+ import plugin from "./plugin.js";
238
+ import { createPlugin, serve } from "initiative-plugin-sdk/server";
239
+
240
+ serve(createPlugin(plugin));
241
+ ```
242
+
243
+ `createPlugin(plugin, options)` returns a web-standard `(Request) => Promise<Response>`
244
+ handler, so the same plug-in runs on any runtime, or behind any framework, that
245
+ speaks `Request` and `Response`. `serve` runs it on `node:http`, on `PORT`
246
+ (default 8080).
247
+
248
+ | Route | |
249
+ |---|---|
250
+ | `GET /healthz`, `GET /readyz` | Answer once the process is up. |
251
+ | `GET /.well-known/jwks.json` | The plug-in's public key. |
252
+ | `GET /.well-known/initiative-plugin.json` | The manifest document: the manifest with the plug-in's id, uid and name. |
253
+ | `GET, POST /v1/endpoints` | What the plug-in declares; Initiative's endpoint calls, on a context token. |
254
+ | `POST /v1/hooks/{name}` | Initiative's hook calls, on a lifecycle token for that hook. |
255
+ | a surface's path | The surface's handler. |
256
+
257
+ Every call's token is verified against the deployment's JWKS, which is cached
258
+ and refetched once for a key it does not know. Each kind carries its own `typ`
259
+ (`initiative-context+jwt` on endpoint and hook calls, `initiative-handoff+jwt` on
260
+ a page handoff), and each path takes only its own kind. Bodies are capped at 5 MiB. A
261
+ refusal answers `{ "error": code, "detail": sentence }`.
262
+
263
+ | Option | Default | |
264
+ |---|---|---|
265
+ | `baseUrl` | `INITIATIVE_BASE_URL` | Initiative's API as the plug-in reaches it, such as `http://initiative:8173/api/v1`. |
266
+ | `key` | `INITIATIVE_PLUGIN_PRIVATE_KEY`, `INITIATIVE_PLUGIN_KEY_ID` | The plug-in's key: PEM, PEM with literal `\n`, or base64 of the PEM, and the `kid` it is registered under (default: its RFC 7638 thumbprint). |
267
+ | `dataDir` | `INITIATIVE_PLUGIN_DATA_DIR`, else `data` | With no key given, one is generated on first start and kept here as `plugin-key.pem`. |
268
+ | `manifest` | `manifest.json` | The built manifest. |
269
+ | `context` | | Handed to every handler as `context`. |
270
+ | `fetch`, `now`, `log`, `env` | | For tests and other runtimes. |
271
+
272
+ **The plug-in's key.** With no key given, the plug-in generates an ES256 key the first
273
+ time it starts, keeps it, and serves its public half at
274
+ `/.well-known/jwks.json`. The deployment running it registers that JWKS with
275
+ Initiative, beside where the plug-in runs, in its `PLUGIN_SERVICES_CONFIG` entry or
276
+ under **Settings → Platform → Integrations → Plug-in services**. At every start
277
+ the plug-in logs each key it serves:
278
+
279
+ ```text
280
+ plug-in key fingerprint: <thumbprint> (kid <kid>)
281
+ ```
282
+
283
+ The fingerprint is the key's RFC 7638 SHA-256 thumbprint, base64url without
284
+ padding, the value Initiative shows when the operator connects the plug-in, so the
285
+ two can be compared. `jwkThumbprint` from `initiative-plugin-sdk/client` computes
286
+ it for a JWKS entry. To bring your own key:
287
+
288
+ ```sh
289
+ npx initiative-plugin keygen --alg ES256 --out ./secrets
290
+ ```
291
+
292
+ `private-key.pem` (mode 0600) stays with the plug-in; `jwks.json` is its public
293
+ half. To rotate, publish a JWKS holding both keys, switch the plug-in to the new
294
+ one, then drop the old entry.
295
+
296
+ **Context.** Your own services reach handlers as `context`. Say what it holds
297
+ once:
298
+
299
+ ```ts
300
+ declare module "initiative-plugin-sdk/manifest" {
301
+ interface PluginContext {
302
+ tracker: TrackerClient;
303
+ }
304
+ }
305
+
306
+ serve(createPlugin(plugin, { context: { tracker: new TrackerClient() } }));
307
+ ```
308
+
309
+ ## 5. Call Initiative
310
+
311
+ A handler's `client` already acts for its call. Outside a call, make one:
312
+
313
+ ```ts
314
+ import { Initiative, loadPrivateKey } from "initiative-plugin-sdk/client";
315
+
316
+ const initiative = new Initiative({
317
+ baseUrl: "https://initiative.example.com/api/v1",
318
+ publicId: "acme.tracker",
319
+ key: loadPrivateKey(pem, "plugin-1"),
320
+ });
321
+
322
+ for (const { installation, active } of await initiative.installations()) {
323
+ if (!active) continue; // paused: keep what you hold for it
324
+ const client = initiative.asInstallation(installation);
325
+ const { items } = await client.api.listProjects({ query: { initiative_id: 12 } });
326
+ }
327
+ ```
328
+
329
+ - `asInstallation(installation, { initiative, scopes })` acts as the community,
330
+ optionally narrowed to one initiative the plug-in is placed in and fewer scopes.
331
+ - `asMember(installation, member, { purpose, initiative })` acts for one
332
+ member, within what they consented to (`client.requestConsent(…)`);
333
+ `ConsentRequiredError` says they have not.
334
+ - `client.api` has a typed method for every route Initiative's plug-in API
335
+ describes, named after the route's operation id in camel case. A call takes
336
+ its arguments by where they go, and answers the route's typed response:
337
+
338
+ ```ts
339
+ const task = await client.api.updateTask({ path: { task_id: 7 }, body: { title: "Ship it" } });
340
+ await client.api.archiveEntity({ path: { entity_type: "document", entity_id: 3 } });
341
+ ```
342
+
343
+ No call is sent unless the token holds the scope it needs: the route's own,
344
+ the one its argument picks (`archiveEntity` on a `document` needs
345
+ `documents:write`), or, where Initiative checks each item, at least one of
346
+ them. `MissingScopeError` names the scope instead. Writing implies reading.
347
+
348
+ `client.api` follows Initiative's plug-in API: when Initiative renames or moves a
349
+ route, the method changes with it in the SDK release built from that
350
+ Initiative release, without a major version. The rest of the SDK follows
351
+ semantic versioning.
352
+ `PluginApiSchemas["TaskRead"]` names a schema's type.
353
+ - `client.request(method, path, { scope, body })` calls a community route (the
354
+ path after `/c/{community}`) by hand, with the same scope check.
355
+ - `client.callPlugin(publicId, endpointId, params)` calls another plug-in's public
356
+ endpoint through Initiative. It needs `plugins:<publicId>` among the plug-in's
357
+ scopes, granted by the community.
358
+ - The installation itself, on any installation token: `client.config()`,
359
+ `client.connections()`, `client.connectionToken(ref)` for a usable vendor
360
+ token, `client.reportConfigStatus({ state, detail })`, and
361
+ `client.emitEvent({ eventType, payload, initiativeId })`.
362
+
363
+ The plug-in authenticates with its key (`private_key_jwt`, RFC 7523). Tokens are
364
+ opaque; the client caches each until shortly before it expires, shares one
365
+ request between concurrent callers, and sends a call answered 401 once more on
366
+ a fresh token. A refusal raises `InitiativeApiError` with Initiative's
367
+ `detail`; the token endpoint's own refusal raises `InitiativeAuthError`.
368
+
369
+ ## 6. Publish
370
+
371
+ Initiative installs plug-ins from signed registries. A listing names the plug-in, its
372
+ versions, the container image each runs (pinned by digest) and the most it may
373
+ ever be granted. It names no keys: each deployment runs its own copy of the
374
+ image and registers the key that copy signs with. Declare it in the definition:
375
+
376
+ ```ts
377
+ listing: {
378
+ publisher: "acme",
379
+ summary: "Your tracker's tickets on a dashboard and in your automations.",
380
+ description: "…",
381
+ avatar: "assets/avatar.png",
382
+ version: "1.2.0",
383
+ minAppVersion: "0.72.0",
384
+ releaseNotes: "…",
385
+ image: "ghcr.io/acme/tracker@sha256:…",
386
+ compose: {
387
+ service: `tracker:
388
+ image: \${IMAGE}
389
+ environment:
390
+ INITIATIVE_URL: \${INITIATIVE_URL}
391
+ volumes: [tracker-keys:/data]`,
392
+ baseUrl: "http://tracker:8080",
393
+ },
394
+ },
395
+ ```
396
+
397
+ `compose` is optional: the Docker Compose service an operator copies to run
398
+ your image beside Initiative, which Initiative shows on the plug-in's settings
399
+ page. `service` is the fragment, YAML text of at most 4096 characters, with two
400
+ placeholders Initiative fills: `${IMAGE}` (`image`, pinned by its digest) and
401
+ `${INITIATIVE_URL}` (the deployment's public address). Any other `${…}` fails
402
+ the build, so a misspelt placeholder cannot ship. `baseUrl` is where the
403
+ service answers on the Compose network, an http or https URL of at most 512
404
+ characters; Initiative pre-fills the deployment's base URL with it.
405
+
406
+ ```sh
407
+ npx initiative-plugin build --registry ../registry/sources
408
+ ```
409
+
410
+ writes the plug-in's registry source under `<publisher>/<uid>/`: `listing.json`,
411
+ this version's `<version>/manifest.json` and the avatar. It is written only
412
+ while `listing.version` is the package's own version: between releases the
413
+ package runs ahead of its listing, and a new version is listed at its release,
414
+ with its image's digest. The registry's CI checks and signs what is merged.
415
+
416
+ A self-hosted deployment can also publish a plug-in that is in no registry, from
417
+ its listing file: see [A private plug-in](#a-private-plug-in).
418
+
419
+ ## Declarative integrations
420
+
421
+ A plug-in that only calls a vendor's API and reshapes the answer needs no
422
+ container. Name the `hosts` it calls and give each endpoint a `request` and a
423
+ `map` in place of a handler: Initiative makes the call with the connection's
424
+ credential, and maps the answer itself. The plug-in has no `service` block, no
425
+ image and nothing to run, and its listing registers it as `declarative`.
426
+
427
+ ```ts
428
+ // src/plugin.ts
429
+ import { definePlugin, defineEndpoint } from "initiative-plugin-sdk/manifest";
430
+
431
+ export const openIssues = defineEndpoint({
432
+ direction: "read",
433
+ label: { en: "Open issues" },
434
+ params: { repo: { type: "string", label: { en: "Repository" }, required: true } },
435
+ requires: { all_of: ["account"] },
436
+ returns: { titles: { type: "string", list: true }, total: "int" },
437
+ request: {
438
+ method: "GET",
439
+ url: '"https://api.tracker.example/repos/" & params.repo & "/issues"',
440
+ query: { state: '"open"' },
441
+ connection: "account",
442
+ },
443
+ map: '{"titles": response.body.title[], "total": $count(response.body)}',
444
+ });
445
+
446
+ export default definePlugin({
447
+ publicId: "acme.tracker",
448
+ uid: "K7M2QX8N4TVB9C",
449
+ name: "Acme Tracker",
450
+ hosts: ["api.tracker.example"],
451
+ connections: {
452
+ account: {
453
+ scope: "interactive",
454
+ label: { en: "Your account" },
455
+ fields: [],
456
+ flow: {
457
+ type: "oauth2",
458
+ authorize_url: "https://tracker.example/oauth/authorize",
459
+ token_url: "https://tracker.example/oauth/token",
460
+ client_id: "{vendor.client_id}",
461
+ },
462
+ },
463
+ },
464
+ vendor: { fields: [{ key: "client_id", type: "string", required: true, label: { en: "Client id" } }] },
465
+ endpoints: { "open-issues": openIssues },
466
+ listing: { publisher: "acme", summary: "Your tracker's issues.", avatar: "assets/avatar.png", version: "1.0.0" },
467
+ });
468
+ ```
469
+
470
+ ```ts
471
+ // test/open-issues.test.ts
472
+ import { expect, it } from "vitest";
473
+ import { runEndpoint } from "initiative-plugin-sdk/testing";
474
+ import plugin from "../src/plugin.js";
475
+ import issues from "./fixtures/issues.json" with { type: "json" };
476
+
477
+ it("lists a repository's open issues", async () => {
478
+ const run = await runEndpoint(plugin, "open-issues", {
479
+ params: { repo: "acme/web" },
480
+ now: "2026-10-01T12:00:00Z",
481
+ responses: [{ status: 200, body: issues }],
482
+ });
483
+ expect(run.requests).toEqual([
484
+ {
485
+ method: "GET",
486
+ url: "https://api.tracker.example/repos/acme/web/issues?state=open",
487
+ headers: {},
488
+ connection: "account",
489
+ },
490
+ ]);
491
+ expect(run).toMatchObject({ result: { titles: ["Broken build"], total: 1 } });
492
+ });
493
+ ```
494
+
495
+ - **Every expression is [JSONata](https://jsonata.org)**, standard, with no
496
+ functions added: the URL, each query parameter and header, the body or a
497
+ GraphQL request's `variables`, and the map. An expression reads `params`,
498
+ `connection` (the non-secret fields of the connection its request names),
499
+ `connections` (on an endpoint, each connection its `requires` names that the
500
+ call has, by id: `connections.workspace.owner`), `now`, and once a call has
501
+ been answered `response` (`status`, `headers`, `body`). `build` parses
502
+ every one and fails with its place in the manifest when one does not parse.
503
+ - **Each evaluation is bounded** by the contract's `CAPS.expressionTimeMs`,
504
+ `CAPS.expressionDepth` and `CAPS.expressionOutputBytes`; past one, or on an
505
+ error, the call answers `unavailable: mapping-failed`. `evaluate` from
506
+ `initiative-plugin-sdk/testing` runs an expression within the same bounds.
507
+ - **Credentials never enter an expression.** Initiative adds the one the
508
+ request's `connection` names, as `auth` says: `Authorization: Bearer
509
+ <token>` unless `auth: { header, prefix }` says otherwise. Every address
510
+ must be https on one of `hosts`, exact or with one leading `*.` label. A
511
+ request or step on an `interactive` connection names it in the endpoint's
512
+ `requires` too, or `build` fails. A call made as a member carries a
513
+ `static` connection's credential only when `requires` names it, and is
514
+ refused otherwise.
515
+ - **`steps`** in place of `request` makes up to three calls in order; each
516
+ reads the earlier ones as `steps.<name>`, and so does the map.
517
+ - **`paging`** on a request reads more pages before the map runs:
518
+ `page_number`, `link_header` or `cursor`, each with `max_pages` (at most 10)
519
+ and `on_limit`, `truncate` or `refuse`.
520
+ - **Errors.** 401 and 403 answer `unavailable: not-authorized`, 404
521
+ `not-found`, other 4xx `invalid`, and 429, 3xx and 5xx are transient.
522
+ `errors` rows match a status (`404` or `"4xx"`) and an optional `when`, such
523
+ as a GraphQL error inside a 200, to a code the endpoint declares in
524
+ `unavailable`, or to `transient`. A map may answer
525
+ `{"unavailable": "<code>"}` itself.
526
+ - **Connections** take a declarative `after_connect` (a request or up to
527
+ three `steps`, a map to `{values, account_label}`, and `refuse_when` with
528
+ its `code`, which read each step as `steps.<name>`) and a
529
+ `health` check run on an interval. **Webhooks** take `events`, each mapping
530
+ a delivery to one of the plug-in's emissions, and `status` rows that set a
531
+ connection's state; a delivery may be routed by a `header` instead of a
532
+ body `path`.
533
+ - **One plug-in is one kind.** A declarative plug-in has no handlers, hooks,
534
+ schedules or surfaces, and asks for no scopes; a container plug-in uses none of
535
+ these terms. `validateManifest` and `build` refuse a mix.
536
+
537
+ `runEndpoint`, `runAfterConnect`, `runHealth` and `runWebhook` render each
538
+ request as Initiative sends it, less the credential, answer it from the
539
+ recorded `responses` in order, and return the requests beside the result or
540
+ the code the run answered. `runEndpoint` takes each connection's fields by id
541
+ in `connections`; it has no actor, so it does not refuse a call the way
542
+ Initiative would for who is making it. A run fails when a request has no recorded answer
543
+ left, when an answer is left over, or when the map's answer does not fit the
544
+ declared returns.
545
+
546
+ ## A private plug-in
547
+
548
+ A deployment's owner can publish a plug-in of their own, which then sits on that
549
+ deployment's shelf beside the registry's. Start one from the example, a
550
+ declarative plug-in with one read and one widget:
551
+
552
+ ```sh
553
+ npx initiative-plugin-sdk init my-plugin # copies examples/minimal, with a uid of its own
554
+ cd my-plugin && npm install && npm test
555
+ ```
556
+
557
+ ```sh
558
+ npx initiative-plugin pack # writes <publicId>-<version>.json
559
+ ```
560
+
561
+ `pack` builds the plug-in as `build` does, checks it the same way, and writes its
562
+ **listing file**: what the catalogue shows, this version's manifest, and the
563
+ plug-in's registration. The deployment publishes it as it is, from
564
+ `POST /api/v1/marketplace/local/upload` (the file as `{"manifest": …}`) or from
565
+ its catalog directory (`MARKETPLACE_EXTRA_CATALOG_DIR`). The listing's picture
566
+ is named by its digest, so upload it beside the file, to
567
+ `POST /api/v1/marketplace/local/media`; PNG, JPEG, GIF and WebP are kept, and
568
+ any other picture leaves the deployment's default mark. Both routes take the
569
+ owner's session or personal API key. A container plug-in packs too, and is live
570
+ once its service is registered on the deployment.
571
+
572
+ ```sh
573
+ npx initiative-plugin dev --initiative https://initiative.example.com --api-key ppk_…
574
+ ```
575
+
576
+ `dev` packs a declarative plug-in and uploads it, with its picture, to your
577
+ deployment, then again a moment after each change in the directory holding
578
+ `src/plugin.ts`. The key may be given as `INITIATIVE_API_KEY` instead. It prints
579
+ each upload and the deployment's answer:
580
+
581
+ ```text
582
+ uploaded example.gitlab-issues 1.0.0-dev.3f9a2c1b (uid ZNV2THEZGPHXXG): 201 {"uid":"ZNV2THEZGPHXXG","public_id":"example.gitlab-issues","version":"1.0.0-dev.3f9a2c1b"}
583
+ watching src for changes
584
+ ```
585
+
586
+ A deployment publishes each version once, so each upload is versioned
587
+ `<listing version>-dev.<digest>`. A community that installed the plug-in moves to
588
+ it from its Update button, or on its own if it follows updates. The plug-in is
589
+ offered once the vendor values its manifest requires are set under the
590
+ deployment's plug-in services.
591
+
592
+ ## Validating by hand
593
+
594
+ ```sh
595
+ npx initiative-plugin validate manifest.json # a manifest, or a served manifest document
596
+ npx initiative-plugin schema # the JSON Schema it checks against
597
+ ```
598
+
599
+ `validateManifest` runs the bundled JSON Schema, then the checks a schema
600
+ cannot express: features against the blocks present, ids that must name
601
+ something the manifest declares, connection, vendor setup and schedule rules,
602
+ and every term the contract does not declare (a deployment discards those
603
+ without saying so). The deployment also enforces byte-size caps.
604
+
605
+ ## The contract
606
+
607
+ `manifest.contract.json` is the one hand-written statement of what a manifest
608
+ may say. `schemas/plugin-manifest.json` and `src/contract.ts` (its types) are
609
+ generated from it with `npm run generate`; `npm run check:generated` fails when
610
+ either is stale. Initiative vendors the contract from this repository's tags.
611
+
612
+ `src/plugin-api.generated.ts`, behind `client.api`, is generated from Initiative's
613
+ plug-in API description, read from Initiative itself and not stored here. Its
614
+ header names the Initiative it came from. Regenerate it from a checkout, a
615
+ release or a running deployment, with the same emitter as the contract's types:
616
+
617
+ ```sh
618
+ npm run generate:plugin-api -- --checkout ../initiative # runs its export with uv
619
+ npm run generate:plugin-api -- --release v0.75.0
620
+ npm run generate:plugin-api -- --url https://initiative.example.com
621
+ ```
622
+
623
+ ## Scopes
624
+
625
+ | Scope | Grants |
626
+ |---|---|
627
+ | `projects:read`, `projects:write` | Projects and what belongs to them: tasks, statuses, checklists. |
628
+ | `documents:read`, `documents:write` | Documents. |
629
+ | `queues:read`, `queues:write` | Queues, their items and commands. |
630
+ | `counter_groups:read`, `counter_groups:write` | Counter groups, their counters and commands. |
631
+ | `calendars:read`, `calendars:write` | Calendars, events and attendees. |
632
+ | `dashboards:read`, `dashboards:write` | Dashboards. |
633
+ | `posts:read`, `posts:write` | Posts, including pinning. |
634
+ | `galleries:read`, `galleries:write` | Galleries. |
635
+ | `wikis:read`, `wikis:write` | Wikis and their pages. |
636
+ | `comments:read`, `comments:write` | Comments on what the plug-in can read. |
637
+ | `relationships:read`, `relationships:write` | Links between items the plug-in can reach. |
638
+ | `tags:read`, `tags:write` | Reading, creating and applying tags. |
639
+ | `sharing:read`, `sharing:write` | Seeing who has access to something, and changing it where the plug-in's own access allows it. |
640
+ | `members:read` | The roster, as references, display names and avatars. |
641
+ | `initiatives:read` | The initiatives the plug-in is placed in. |
642
+ | `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. |
643
+ | `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. |
644
+ | `plugins:<public id>` | Calling that plug-in's public endpoints through Initiative. One per plug-in. |
645
+
646
+ Writing implies reading. Within its scopes a plug-in still sees only what is open
647
+ to the initiative, shared with the plug-in, or created by it — unless a token asks
648
+ for one of the two standings above. Those are never on a token by default: ask
649
+ for one with `asInstallation(installation, { initiative, level: "moderator" })`
650
+ or `asInstallation(installation, { level: "community_admin" })`, and Initiative
651
+ refuses it unless the community granted the matching scope. A member token
652
+ takes no `level`.
653
+
654
+ ## Licence
655
+
656
+ MIT