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.
- package/LICENSE +21 -0
- package/README.md +656 -0
- package/dist/build.d.ts +52 -0
- package/dist/build.js +229 -0
- package/dist/build.js.map +1 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +160 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +288 -0
- package/dist/client.js +539 -0
- package/dist/client.js.map +1 -0
- package/dist/contract.d.ts +1247 -0
- package/dist/contract.js +167 -0
- package/dist/contract.js.map +1 -0
- package/dist/define.d.ts +336 -0
- package/dist/define.js +120 -0
- package/dist/define.js.map +1 -0
- package/dist/dev.d.ts +28 -0
- package/dist/dev.js +147 -0
- package/dist/dev.js.map +1 -0
- package/dist/expression.d.ts +28 -0
- package/dist/expression.js +67 -0
- package/dist/expression.js.map +1 -0
- package/dist/init.d.ts +14 -0
- package/dist/init.js +52 -0
- package/dist/init.js.map +1 -0
- package/dist/keys.d.ts +85 -0
- package/dist/keys.js +109 -0
- package/dist/keys.js.map +1 -0
- package/dist/manifest.d.ts +8 -0
- package/dist/manifest.js +8 -0
- package/dist/manifest.js.map +1 -0
- package/dist/pack.d.ts +35 -0
- package/dist/pack.js +84 -0
- package/dist/pack.js.map +1 -0
- package/dist/plugin-api.generated.d.ts +4263 -0
- package/dist/plugin-api.generated.js +1019 -0
- package/dist/plugin-api.generated.js.map +1 -0
- package/dist/server.d.ts +75 -0
- package/dist/server.js +408 -0
- package/dist/server.js.map +1 -0
- package/dist/testing.d.ts +116 -0
- package/dist/testing.js +386 -0
- package/dist/testing.js.map +1 -0
- package/dist/tokens.d.ts +161 -0
- package/dist/tokens.js +215 -0
- package/dist/tokens.js.map +1 -0
- package/dist/validate.d.ts +86 -0
- package/dist/validate.js +997 -0
- package/dist/validate.js.map +1 -0
- package/dist/widget.d.ts +235 -0
- package/dist/widget.js +26 -0
- package/dist/widget.js.map +1 -0
- package/examples/minimal/README.md +48 -0
- package/examples/minimal/assets/avatar.png +0 -0
- package/examples/minimal/package.json +22 -0
- package/examples/minimal/src/plugin.ts +77 -0
- package/examples/minimal/src/widgets/open-issues.ts +11 -0
- package/examples/minimal/test/fixtures/issues-statistics.json +9 -0
- package/examples/minimal/test/issue-counts.test.ts +48 -0
- package/examples/minimal/tsconfig.json +14 -0
- package/manifest.contract.json +1886 -0
- package/package.json +92 -0
- 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
|