@fntio/infra-client-plugin-sdk 0.0.0-stage → 0.2.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 (58) hide show
  1. package/LICENSE +93 -0
  2. package/README.md +279 -2
  3. package/dist/backend/context.d.ts +31 -0
  4. package/dist/backend/context.js +58 -0
  5. package/dist/backend/index.d.ts +9 -0
  6. package/dist/backend/index.js +26 -0
  7. package/dist/browser/client.d.ts +59 -0
  8. package/dist/browser/client.js +288 -0
  9. package/dist/browser/errors.d.ts +14 -0
  10. package/dist/browser/errors.js +32 -0
  11. package/dist/browser/http.d.ts +72 -0
  12. package/dist/browser/http.js +170 -0
  13. package/dist/browser/index.d.ts +11 -0
  14. package/dist/browser/index.js +28 -0
  15. package/dist/browser/theme-apply.d.ts +22 -0
  16. package/dist/browser/theme-apply.js +22 -0
  17. package/dist/contracts/access.d.ts +14 -0
  18. package/dist/contracts/access.js +41 -0
  19. package/dist/contracts/authorizer.d.ts +17 -0
  20. package/dist/contracts/authorizer.js +44 -0
  21. package/dist/contracts/canonical.d.ts +2 -0
  22. package/dist/contracts/canonical.js +21 -0
  23. package/dist/contracts/catalog.d.ts +37 -0
  24. package/dist/contracts/catalog.js +18 -0
  25. package/dist/contracts/discovery.d.ts +78 -0
  26. package/dist/contracts/discovery.js +48 -0
  27. package/dist/contracts/fields.d.ts +7 -0
  28. package/dist/contracts/fields.js +2 -0
  29. package/dist/contracts/manifest.d.ts +59 -0
  30. package/dist/contracts/manifest.js +19 -0
  31. package/dist/contracts/messages.d.ts +108 -0
  32. package/dist/contracts/messages.js +103 -0
  33. package/dist/contracts/paths.d.ts +20 -0
  34. package/dist/contracts/paths.js +49 -0
  35. package/dist/contracts/theme.d.ts +21 -0
  36. package/dist/contracts/theme.js +60 -0
  37. package/dist/contracts/transport.d.ts +14 -0
  38. package/dist/contracts/transport.js +10 -0
  39. package/dist/contracts/validate.d.ts +27 -0
  40. package/dist/contracts/validate.js +321 -0
  41. package/dist/fixtures/devHost.d.ts +57 -0
  42. package/dist/fixtures/devHost.js +91 -0
  43. package/dist/fixtures/fakeDocument.d.ts +12 -0
  44. package/dist/fixtures/fakeDocument.js +22 -0
  45. package/dist/fixtures/fakeWindows.d.ts +22 -0
  46. package/dist/fixtures/fakeWindows.js +46 -0
  47. package/dist/fixtures/index.d.ts +4 -0
  48. package/dist/fixtures/index.js +20 -0
  49. package/dist/fixtures/samples.d.ts +37 -0
  50. package/dist/fixtures/samples.js +220 -0
  51. package/dist/host/host.d.ts +47 -0
  52. package/dist/host/host.js +117 -0
  53. package/dist/host/index.d.ts +5 -0
  54. package/dist/host/index.js +22 -0
  55. package/dist/index.d.ts +10 -0
  56. package/dist/index.js +28 -0
  57. package/package.json +97 -4
  58. package/skills/build-infra-client-plugin/SKILL.md +61 -0
package/LICENSE ADDED
@@ -0,0 +1,93 @@
1
+ Elastic License 2.0
2
+
3
+ URL: https://www.elastic.co/licensing/elastic-license
4
+
5
+ ## Acceptance
6
+
7
+ By using the software, you agree to all of the terms and conditions below.
8
+
9
+ ## Copyright License
10
+
11
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
12
+ non-sublicensable, non-transferable license to use, copy, distribute, make
13
+ available, and prepare derivative works of the software, in each case subject to
14
+ the limitations and conditions below.
15
+
16
+ ## Limitations
17
+
18
+ You may not provide the software to third parties as a hosted or managed
19
+ service, where the service provides users with access to any substantial set of
20
+ the features or functionality of the software.
21
+
22
+ You may not move, change, disable, or circumvent the license key functionality
23
+ in the software, and you may not remove or obscure any functionality in the
24
+ software that is protected by the license key.
25
+
26
+ You may not alter, remove, or obscure any licensing, copyright, or other notices
27
+ of the licensor in the software. Any use of the licensor’s trademarks is subject
28
+ to applicable law.
29
+
30
+ ## Patents
31
+
32
+ The licensor grants you a license, under any patent claims the licensor can
33
+ license, or becomes able to license, to make, have made, use, sell, offer for
34
+ sale, import and have imported the software, in each case subject to the
35
+ limitations and conditions in this license. This license does not cover any
36
+ patent claims that you cause to be infringed by modifications or additions to
37
+ the software. If you or your company make any written claim that the software
38
+ infringes or contributes to infringement of any patent, your patent license for
39
+ the software granted under these terms ends immediately. If your company makes
40
+ such a claim, your patent license ends immediately for work on behalf of your
41
+ company.
42
+
43
+ ## Notices
44
+
45
+ You must ensure that anyone who gets a copy of any part of the software from you
46
+ also gets a copy of these terms.
47
+
48
+ If you modify the software, you must include in any modified copies of the
49
+ software prominent notices stating that you have modified the software.
50
+
51
+ ## No Other Rights
52
+
53
+ These terms do not imply any licenses other than those expressly granted in
54
+ these terms.
55
+
56
+ ## Termination
57
+
58
+ If you use the software in violation of these terms, such use is not licensed,
59
+ and your licenses will automatically terminate. If the licensor provides you
60
+ with a notice of your violation, and you cease all violation of this license no
61
+ later than 30 days after you receive that notice, your licenses will be
62
+ reinstated retroactively. However, if you violate these terms after such
63
+ reinstatement, any additional violation of these terms will cause your licenses
64
+ to terminate automatically and permanently.
65
+
66
+ ## No Liability
67
+
68
+ *As far as the law allows, the software comes as is, without any warranty or
69
+ condition, and the licensor will not be liable to you for any damages arising
70
+ out of these terms or the use or nature of the software, under any kind of
71
+ legal claim.*
72
+
73
+ ## Definitions
74
+
75
+ The **licensor** is the entity offering these terms, and the **software** is the
76
+ software the licensor makes available under these terms, including any portion
77
+ of it.
78
+
79
+ **you** refers to the individual or entity agreeing to these terms.
80
+
81
+ **your company** is any legal entity, sole proprietorship, or other kind of
82
+ organization that you work for, plus all organizations that have control over,
83
+ are under the control of, or are under common control with that
84
+ organization. **control** means ownership of substantially all the assets of an
85
+ entity, or the power to direct its management and policies by vote, contract, or
86
+ otherwise. Control can be direct or indirect.
87
+
88
+ **your licenses** are all the licenses granted to you for the software under
89
+ these terms.
90
+
91
+ **use** means anything you do with the software requiring one of your licenses.
92
+
93
+ **trademark** means trademarks, service marks, and similar rights.
package/README.md CHANGED
@@ -1,3 +1,280 @@
1
- # Temporary Holding Version
1
+ # @fntio/infra-client-plugin-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The contract every part of a customer plugin agrees on: the manifest a deployment
4
+ registers, the catalog record it becomes, the discovery responses the portal reads,
5
+ the messages a plugin iframe exchanges with the portal shell, and the context the
6
+ shared authorizer hands a plugin Lambda. Types plus runtime validators, no runtime
7
+ dependencies.
8
+
9
+ Status: contracts (issue #19), the iframe SDK with its host adapter (issue #24) and the
10
+ backend handler helpers (issue #22). Nothing here is published yet; the package is private
11
+ until the publishing workflow enables it.
12
+
13
+ ## Entry points
14
+
15
+ | Import | For | Contains |
16
+ | --- | --- | --- |
17
+ | `@fntio/infra-client-plugin-sdk` | anyone | the contracts every entry shares: types, validators, path and key helpers (no runtime code) |
18
+ | `@fntio/infra-client-plugin-sdk/browser` | plugin frontends | `connectToInfraClient`, the `PluginContext` it resolves, `context.api`, theme application, message protocol, theme tokens, discovery types |
19
+ | `@fntio/infra-client-plugin-sdk/host` | the portal shell, the dev host | `createPluginHost`: the host side of the protocol |
20
+ | `@fntio/infra-client-plugin-sdk/backend` | plugin Lambdas, the core API | manifest + validator, catalog record, access target keys, authorizer context, path helpers, `pluginAuthorizerContextOf`, `pluginRoutePath`, `rebasePluginEvent`, `createPluginLogger` |
21
+ | `@fntio/infra-client-plugin-sdk/fixtures` | tests in any package, local plugin development | valid manifests in every shape, one invalid manifest per rule, sample payloads, `createFakeWindowPair`, `createDevHost` |
22
+
23
+ The browser, host and backend entries import nothing external; a test fails if that changes.
24
+
25
+ ## Contracts
26
+
27
+ - **Manifest** (`PluginManifestV1`, `validatePluginManifest`, `assertPluginManifest`).
28
+ `schemaVersion: 1`, `hostApiVersion: 1`, id, display name, version, enabled flag,
29
+ account bindings, `user` and/or `admin` permissions, zero to two UI entries, routes.
30
+ The validator reports every problem at once with a stable `code`; the codes are the
31
+ `PluginValidationCode` union. `admin` implies `user` only when both are declared
32
+ (`pluginPermissionsImpliedBy`).
33
+ - **Paths.** Routes mount at `/api/plugins/<id>/accounts/{accountNumber}<path>`
34
+ (`pluginRouteResourcePath`); assets at `/plugin-assets/<id>/<version>-<hash>/`
35
+ (`pluginAssetPrefix`). `{accountNumber}` is reserved for the platform.
36
+ - **Access target** (`pluginTargetKey`): `plugin#<id>#<account>#<user|admin>`, the
37
+ Access-table target string for plugin grants.
38
+ - **Catalog record** (`PluginCatalogRecordV1`, `pluginCatalogStatus`): what the
39
+ PluginCatalog table stores per plugin. Holds no user grants. Status is `active`,
40
+ `disabled`, `removed` (tombstone) or `unsupported`.
41
+ - **Discovery** (`PluginDiscoveryResponseV1` for `GET /api/plugins`,
42
+ `PluginAdminCatalogResponseV1` for `GET /api/admin/plugins`).
43
+ - **Messages** (`createPluginMessage`, `parsePluginMessage`): the `postMessage`
44
+ envelope `{ protocol: "infra-client-plugin", version: 2, type, instanceId, requestId?, payload }`.
45
+ Host → plugin: `init`, `sessionChanged`, `sessionEnded`, `accessChanged`,
46
+ `themeChanged`. Plugin → host: `ready`, `requestSessionRefresh`,
47
+ `requestAccessRefresh`. `parsePluginMessage` returns `null` for
48
+ anything malformed; callers still check `event.source` and `event.origin`.
49
+ - **Theme** (`PLUGIN_THEME_TOKENS`, `PluginTheme`): the documented subset of the
50
+ portal's CSS custom properties, plus appearance and branding.
51
+ - **Authorizer context** (`PluginAuthorizerContextV1`, `parsePluginAuthorizerContext`):
52
+ the string fields the shared authorizer attaches to an allowed request. Parsing fails
53
+ closed.
54
+
55
+ ## Browser SDK
56
+
57
+ A plugin frontend runs in an iframe on the portal's origin and never signs in itself. The
58
+ host owns sign-in and the refresh token; the SDK receives the current bearer token over
59
+ `postMessage`, keeps it in memory, and hands it to requests the iframe makes itself.
60
+
61
+ ```ts
62
+ import { connectToInfraClient } from "@fntio/infra-client-plugin-sdk/browser";
63
+
64
+ const context = await connectToInfraClient(); // posts `ready`, resolves on `init`
65
+ const [account] = context.accounts(); // every account the caller may use; choosing one is yours
66
+ const items = await context.api.get(`accounts/${account.accountNumber}/parameters`);
67
+ context.on("accessChanged", () => render());
68
+ context.on("themeChanged", () => {}); // the SDK already re-applied the tokens
69
+ context.on("sessionEnded", () => showSignedOut());
70
+ ```
71
+
72
+ `connectToInfraClient` rejects with `not_framed` when the page is a top-level document (a user
73
+ opened the plugin URL directly), `invalid_options` when something it needs is missing or the
74
+ host window refuses the message (outside a browser pass `hostWindow`, `hostOrigin`, `listenOn`
75
+ and `fetch`), `incompatible_host` when the host speaks another protocol version, and
76
+ `handshake_timeout` (10 s, re-announcing every second; 0 disables) when no `init` arrives;
77
+ call it again to retry. Every error is a `PluginSdkError` with a `code`.
78
+
79
+ **Session.** `context.session.current()` is the token and expiry. `token()` returns the current
80
+ token at once while it is valid; inside the last 30 s (`refreshLeadMs`) it also asks the host to
81
+ renew in the background, and only once the token has expired does it wait for the host. Any
82
+ `sessionChanged` the host sends satisfies a pending refresh, whether it answers the request or
83
+ was pushed by the host's own timer. `refresh(reason)` asks outright; concurrent callers share
84
+ one round trip. A refresh nobody answers within 15 s (`refreshTimeoutMs`, 0 disables) rejects
85
+ with `refresh_timeout`. After the session ends every call rejects with `session_ended`.
86
+
87
+ **Requests.** `context.api` (`get`/`post`/`put`/`patch`/`delete` for JSON, `request` for a raw
88
+ `Response`, `url` for other clients) resolves paths under `plugin.apiBaseUrl`, refuses absolute
89
+ URLs and `..` segments so the token is not sent outside the plugin's own API root by mistake (a
90
+ guardrail, not a boundary — see the trust model below), and sends `Authorization: Bearer`. After an
91
+ authentication-layer 401 it asks the host to renew and retries exactly once: the authorizer
92
+ rejects before plugin code runs, so the retry cannot repeat a completed write. The retry is
93
+ skipped, and the 401 surfaces, when the body is a stream, when the account in the request path
94
+ (`accounts/<accountNumber>/…`, read by `pluginRequestAccount`) is not in `accounts()` at the 401 or
95
+ no longer there once the renewal returns, or when the host hands back the very token that was
96
+ rejected (`canRetryAfterUnauthorized` is that rule, shared with the Angular interceptor). A
97
+ request whose path names no account is not held to the list. Timeouts
98
+ (`timeoutMs`, which also covers reading the body), 5xx and 403 are never retried. A 403 also
99
+ sends `requestAccessRefresh` (`forbidden`) so the host reloads discovery; pass
100
+ `accessRefreshOnForbidden: false` to keep that manual. Plugin business handlers should answer
101
+ with domain 4xx codes, never 401.
102
+
103
+ **Context.** `accounts()`, `permissionsFor(accountNumber)`,
104
+ `hasPermission(permission, accountNumber)`, `entryAvailable()` and `theme()` follow
105
+ `accessChanged` and `themeChanged`. `accounts()` is every account the caller may use on this
106
+ entry, each with its permissions; an account outside the list has none. The portal tracks no
107
+ selected account: which one the plugin works in, if it has the notion at all, is the plugin's
108
+ own state, as is any picker it draws. Re-check that state on `accessChanged`, since the account
109
+ may have left the list.
110
+
111
+ **Theme.** On `init` and every `themeChanged` the SDK writes the documented tokens into one
112
+ `<style>` element (`applyPluginTheme`) and sets `data-theme="light|dark"` on `<html>`. An
113
+ iframe inherits none of the parent's custom properties, so this is the only way in. Pass
114
+ `applyTheme: false` to theme the document yourself, or your own function to receive each
115
+ theme.
116
+
117
+ **End of session.** `sessionEnded` marks the context ended, fails pending refreshes with
118
+ `session_ended`, drops listeners and stops listening: a late message from the old frame
119
+ cannot restore a session. `dispose()` is the plugin's own cleanup and does the same locally.
120
+
121
+ **What the shipped portal sends.** The contract allows more than the portal uses today
122
+ (`frontend/src/app/features/plugins/plugin-page.component.ts`), so handle every value but
123
+ expect these: `sessionEnded` arrives with reason `expired` (a renewal failed: the 12 h wall)
124
+ or `unloaded` (the user left the page, reloaded the frame, or the entry vanished from
125
+ discovery), never `signedOut` — signing out navigates away, which is `unloaded`.
126
+ `accessChanged` always carries `entryAvailable: true`: when the entry itself is revoked or
127
+ disabled the portal tears the frame down and shows its own "access ended" page instead.
128
+ `init.user` has `subject`, `principalType: "user"` and `email` when known; `displayName` is
129
+ never set. `createDevHost` (under Local development) is broader than the portal on all three
130
+ points.
131
+
132
+ ## Host adapter
133
+
134
+ `createPluginHost` (entry `/host`) is the portal side. It validates `event.source` against
135
+ the iframe's window and `event.origin` against the expected origin, answers `ready` with
136
+ `init` built from `initialState()` (read each time, so a reloaded frame starts current),
137
+ forwards `sendSession`/`sendAccess`/`sendTheme`, coalesces
138
+ `requestSessionRefresh` into one `refreshSession()` call and replies to each request id, and
139
+ drops messages from a replaced instance. A `null`, rejecting or throwing renewal sends
140
+ `sessionEnded(expired)` and ends the host. `onIncompatible` reports a frame on another protocol
141
+ version (the portal shows its "built for a different version" page); `onHandshakeTimeout` fires when no `ready` arrives (0 disables) — it counts from when the
142
+ host is created, which is before the frame's assets have loaded, so allow for that or pass 0 and
143
+ time out yourself. `end(reason)` is terminal; `dispose()` sends `unloaded` from the host's own
144
+ teardown. `onEnded` fires for every `end`/`dispose`, connected or not, so discriminate on `reason`.
145
+ Message classification (`inspectPluginMessage`) lives in the contracts, shared with the plugin side.
146
+
147
+ A renewal answers once, and only the frame that asked for it: a frame that reloads mid-renewal is
148
+ not given the answer, and its failure does not end the session of the frame that replaced it.
149
+
150
+ Both ends require an exact origin match. Plugins are same-origin by design, so do not render the
151
+ frame with a `sandbox` attribute that omits `allow-same-origin` — the document's origin becomes
152
+ `null`, every message is dropped, and the only symptom is a handshake timeout.
153
+
154
+ **Trust model.** Because the plugin page is served from the portal's own origin, it runs with the
155
+ signed-in user's full authority: it could read the portal's tokens from `localStorage`, call any
156
+ core API as that user and script the parent document. The message protocol and `context.api`'s
157
+ path pinning protect a well-behaved plugin from mistakes; they are not a sandbox. Installing a
158
+ plugin means trusting its author and its dependencies as you trust the portal bundle. The portal
159
+ enforces a Content-Security-Policy on `/plugin-assets/*` that lets a plugin page load from and talk
160
+ to the portal only — no third-party scripts, no sending data to other hosts.
161
+
162
+ ## Local development
163
+
164
+ `createDevHost` (entry `/fixtures`) drives a plugin through the real protocol with a fake
165
+ identity and fixture tokens (`fixture-token-N`, accepted by no backend). Create it before
166
+ setting the iframe's `src`:
167
+
168
+ ```ts
169
+ import { createDevHost } from "@fntio/infra-client-plugin-sdk/fixtures";
170
+
171
+ const frame = document.querySelector("iframe")!;
172
+ const host = createDevHost({ frame, listenOn: window, origin: location.origin, entry: "admin", onEvent: console.log });
173
+ frame.src = "/plugin/index.html#/admin";
174
+ // scenarios: host.renewSession(), host.expireSession(), host.failNextRefresh(),
175
+ // host.revoke(n), host.grant(a), host.setEntryAvailable(false),
176
+ // host.setAppearance("dark"), host.signOut()
177
+ ```
178
+
179
+ Its defaults differ from the shipped portal on purpose, so a plugin can be exercised against
180
+ states the portal does not produce today: the user is `dev-user` with an `email` and a
181
+ `displayName` (the portal sends no `displayName`), the accounts are the sample set, tokens
182
+ live five minutes (`tokenLifetimeMs`), `setEntryAvailable(false)` sends an `accessChanged`
183
+ the portal never sends, and `signOut()` ends with reason `signedOut`, which the portal never
184
+ uses (see "What the shipped portal sends" under Browser SDK).
185
+
186
+ The usual arrangement is one dev server for the plugin's own assets, with the portal's API
187
+ proxied so `/api/plugins/<id>/...` reaches a real deployment:
188
+
189
+ ```js
190
+ // vite.config.js, webpack devServer, or whatever this project already uses
191
+ proxy: { "/api": { target: "https://portal.example.com", changeOrigin: true } }
192
+ ```
193
+
194
+ Point `plugin.apiBaseUrl` at whatever your dev server proxies to a real deployment or a
195
+ local backend. The fixture never bypasses production authentication: it exercises the
196
+ protocol, the theme and the error paths offline, and anything touching a real API needs a
197
+ real sign-in through the portal. For unit tests, `createFakeWindowPair` gives two windows
198
+ wired like a page and its frame (browser semantics: `postMessage` on a window delivers to
199
+ that window) and `createFakeThemeDocument` the slice of `document` the SDK themes.
200
+
201
+ ## Backend handlers
202
+
203
+ A plugin route's Lambda receives the ordinary REST proxy event. The shared authorizer has
204
+ already answered who and whether: it verified the token with the core's rules (ID token for
205
+ humans, scoped access token plus a live registry row for service users), resolved the route
206
+ from the catalog by method and resource template, took the account from the path, and asked
207
+ the core's access resolver over strongly consistent reads. What it attached is scalar context,
208
+ never a token.
209
+
210
+ ```ts
211
+ import { createPluginLogger, pluginAuthorizerContextOf, pluginRoutePath } from "@fntio/infra-client-plugin-sdk/backend";
212
+
213
+ export async function handler(event: APIGatewayProxyEvent) {
214
+ const ctx = pluginAuthorizerContextOf(event); // throws if absent or malformed: never run without it
215
+ const log = createPluginLogger(ctx); // JSON lines carrying plugin, account, route, subject, correlation
216
+ const route = pluginRoutePath(event.path, ctx); // "/parameters/db-url" for the registered path
217
+ log.info("listing", { route });
218
+ ...
219
+ }
220
+ ```
221
+
222
+ `ctx.accountNumber` is the account the platform authorized; a handler works in that account
223
+ and no other. `ctx.requiredPermission` is the permission the route was registered with, not
224
+ an assertion about anything else the caller holds. `rebasePluginEvent(event, ctx)` returns a
225
+ copy whose `path` is account-relative, for an Express app mounted at `/` behind a proxy
226
+ adapter. Business handlers answer domain 4xx codes, never 401: the browser SDK retries once
227
+ after a 401 on the assumption that the authorizer, not the handler, produced it.
228
+
229
+ Revocation limits: a check that begins after a revoke completed sees it (no cached decisions),
230
+ a request already authorized finishes, and a job the plugin accepted runs until the plugin's
231
+ own cancellation rules stop it.
232
+
233
+ ## Agent skills
234
+
235
+ `skills/build-infra-client-plugin/` ships with this package. Point an agent at it and it will build a
236
+ plugin in the conventions of the repository it is running in, reading this README for the API rather
237
+ than carrying its own copy of it. The integration half — adding a finished plugin to a CDK application
238
+ that already exists — is `skills/integrate-infra-client-plugin/` in
239
+ `@fntio/infra-client-plugin-cdk`.
240
+
241
+ Make them available the way your agent setup already does: most read a `skills/` directory inside
242
+ `node_modules`, or you can copy the folder into your own skills location. There is no installer.
243
+
244
+ ## Versioning
245
+
246
+ Three numbers move independently and are checked at runtime:
247
+
248
+ | Number | Where | Bumps when |
249
+ | --- | --- | --- |
250
+ | `schemaVersion` | manifest, catalog record | the manifest shape changes incompatibly |
251
+ | `hostApiVersion` | manifest | the host stops loading plugins built for an older contract |
252
+ | `version` (protocol) | messages | the iframe message envelope or payloads change incompatibly |
253
+
254
+ The protocol is at version 2 (`PLUGIN_PROTOCOL_VERSION`, SDK 0.2.0). Version 1 (SDK 0.1.x) had
255
+ the portal track a selected account: `selectedAccount` in `init`/`contextChanged`/`accessChanged`
256
+ and a `selectAccount` request. Version 2 removes all of it, so the two do not interoperate in
257
+ either direction: a page built with SDK 0.1.x shows the portal's "built for a different version"
258
+ state until it is rebuilt against 0.2.x, and a 0.2.x page in an older portal rejects with
259
+ `incompatible_host`. `schemaVersion` and `hostApiVersion` are still 1.
260
+
261
+ A frame also reports the SDK package version it was built with (`sdkVersion` in `ready`,
262
+ surfaced by the host adapter's `onReady`). That number is informational; `protocolVersion`
263
+ is the compatibility gate.
264
+
265
+ The npm package follows semver. While it is `0.x`, an incompatible change bumps the
266
+ minor version and one of the numbers above; a compatible addition bumps the patch
267
+ version. The three plugin packages (`-sdk`, `-cdk`, `-ui`) release together at the
268
+ same version.
269
+
270
+ ## Develop
271
+
272
+ ```
273
+ cd packages/infra-client-plugin-sdk
274
+ npx tsc --noEmit && npm test
275
+ ```
276
+
277
+ Workspace installs run at the repository root (`npm install`), never here.
278
+ `npm run build:plugins` at the root compiles `dist/` for all three plugin packages
279
+ in dependency order; the CDK and UI packages type-check and test against this
280
+ package's `src/` directly, so no build is needed for day-to-day checks.
@@ -0,0 +1,31 @@
1
+ import { PluginAuthorizerContextV1 } from "../contracts/authorizer";
2
+ /** The slice of a REST proxy event a plugin handler needs; `APIGatewayProxyEvent` satisfies it. */
3
+ export interface PluginProxyEventLike {
4
+ readonly path: string;
5
+ readonly httpMethod: string;
6
+ readonly requestContext: {
7
+ readonly authorizer?: unknown;
8
+ readonly requestId?: string;
9
+ } | null;
10
+ }
11
+ /** The context the shared authorizer attached. Throws when absent or malformed: a handler must never run
12
+ * on a request the authorizer did not describe. */
13
+ export declare function pluginAuthorizerContextOf(event: PluginProxyEventLike): PluginAuthorizerContextV1;
14
+ /** The account-relative path a route was registered with: `/api/plugins/<id>/accounts/<acct>/x` → `/x`,
15
+ * the account root → `/`. `null` when the path is not under this context's account root, which a handler
16
+ * should treat as a bad request rather than guess. */
17
+ export declare function pluginRoutePath(path: string, ctx: Pick<PluginAuthorizerContextV1, "pluginId" | "accountNumber">): string | null;
18
+ /** A copy of the event whose `path` is the account-relative one, for an Express app mounted at `/` behind
19
+ * a proxy adapter. Everything else, the authorizer context included, is left as it was. */
20
+ export declare function rebasePluginEvent<E extends PluginProxyEventLike>(event: E, ctx: PluginAuthorizerContextV1): E;
21
+ export type PluginLogLevel = "info" | "warn" | "error";
22
+ export interface PluginLogger {
23
+ info(msg: string, extra?: Record<string, unknown>): void;
24
+ warn(msg: string, extra?: Record<string, unknown>): void;
25
+ error(msg: string, extra?: Record<string, unknown>): void;
26
+ }
27
+ /** JSON lines carrying the authorizer context (who, which plugin, which account, which route, correlation),
28
+ * so a handler's own lines correlate with the platform's decision logs. The context always wins over the
29
+ * caller's own fields, and top-level keys that look like secrets are redacted — but a secret nested inside
30
+ * a value (an event, a header bag) is written as given: keeping those out is the plugin's own job. */
31
+ export declare function createPluginLogger(ctx: PluginAuthorizerContextV1, sink?: (line: string) => void): PluginLogger;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pluginAuthorizerContextOf = pluginAuthorizerContextOf;
4
+ exports.pluginRoutePath = pluginRoutePath;
5
+ exports.rebasePluginEvent = rebasePluginEvent;
6
+ exports.createPluginLogger = createPluginLogger;
7
+ const authorizer_1 = require("../contracts/authorizer");
8
+ const paths_1 = require("../contracts/paths");
9
+ /** The context the shared authorizer attached. Throws when absent or malformed: a handler must never run
10
+ * on a request the authorizer did not describe. */
11
+ function pluginAuthorizerContextOf(event) {
12
+ return (0, authorizer_1.parsePluginAuthorizerContext)(event.requestContext?.authorizer);
13
+ }
14
+ /** The account-relative path a route was registered with: `/api/plugins/<id>/accounts/<acct>/x` → `/x`,
15
+ * the account root → `/`. `null` when the path is not under this context's account root, which a handler
16
+ * should treat as a bad request rather than guess. */
17
+ function pluginRoutePath(path, ctx) {
18
+ const root = (0, paths_1.pluginAccountPath)(ctx.pluginId, ctx.accountNumber);
19
+ if (path === root)
20
+ return "/";
21
+ if (!path.startsWith(`${root}/`))
22
+ return null;
23
+ return path.slice(root.length);
24
+ }
25
+ /** A copy of the event whose `path` is the account-relative one, for an Express app mounted at `/` behind
26
+ * a proxy adapter. Everything else, the authorizer context included, is left as it was. */
27
+ function rebasePluginEvent(event, ctx) {
28
+ const path = pluginRoutePath(event.path, ctx);
29
+ if (path === null)
30
+ throw new Error(`${event.path} is outside ${ctx.pluginId}/${ctx.accountNumber}`);
31
+ return { ...event, path };
32
+ }
33
+ const SENSITIVE = /token|authorization|secret|password|credential/i;
34
+ /** JSON lines carrying the authorizer context (who, which plugin, which account, which route, correlation),
35
+ * so a handler's own lines correlate with the platform's decision logs. The context always wins over the
36
+ * caller's own fields, and top-level keys that look like secrets are redacted — but a secret nested inside
37
+ * a value (an event, a header bag) is written as given: keeping those out is the plugin's own job. */
38
+ function createPluginLogger(ctx, sink = (line) => console.log(line)) {
39
+ const base = {
40
+ pluginId: ctx.pluginId,
41
+ accountNumber: ctx.accountNumber,
42
+ routeId: ctx.routeId,
43
+ subject: ctx.subject,
44
+ principalType: ctx.principalType,
45
+ correlationId: ctx.correlationId,
46
+ };
47
+ const write = (level, msg, extra = {}) => {
48
+ const safe = {};
49
+ for (const [key, value] of Object.entries(extra))
50
+ safe[key] = SENSITIVE.test(key) ? "[redacted]" : value;
51
+ sink(JSON.stringify({ level, msg, ...safe, ...base }));
52
+ };
53
+ return {
54
+ info: (msg, extra) => write("info", msg, extra),
55
+ warn: (msg, extra) => write("warn", msg, extra),
56
+ error: (msg, extra) => write("error", msg, extra),
57
+ };
58
+ }
@@ -0,0 +1,9 @@
1
+ export * from "../contracts/manifest";
2
+ export * from "../contracts/validate";
3
+ export * from "../contracts/canonical";
4
+ export * from "../contracts/paths";
5
+ export * from "../contracts/access";
6
+ export * from "../contracts/catalog";
7
+ export * from "../contracts/discovery";
8
+ export * from "../contracts/authorizer";
9
+ export * from "./context";
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ // Entry point for plugin Lambda handlers and the core backend. Must stay free of Angular imports.
18
+ __exportStar(require("../contracts/manifest"), exports);
19
+ __exportStar(require("../contracts/validate"), exports);
20
+ __exportStar(require("../contracts/canonical"), exports);
21
+ __exportStar(require("../contracts/paths"), exports);
22
+ __exportStar(require("../contracts/access"), exports);
23
+ __exportStar(require("../contracts/catalog"), exports);
24
+ __exportStar(require("../contracts/discovery"), exports);
25
+ __exportStar(require("../contracts/authorizer"), exports);
26
+ __exportStar(require("./context"), exports);
@@ -0,0 +1,59 @@
1
+ import { PluginPermission } from "../contracts/manifest";
2
+ import { HostToPluginMessageType, HostToPluginPayloads, PluginAccountContext, PluginEntryKind, PluginInfoContext, PluginToHostPayloads, PluginUserContext } from "../contracts/messages";
3
+ import { PluginTheme } from "../contracts/theme";
4
+ import { PluginMessageListenerTarget, PluginMessagePort } from "../contracts/transport";
5
+ import { FetchLike, PluginApi, PluginTokenProvider } from "./http";
6
+ export declare const PLUGIN_SDK_VERSION = "0.2.0";
7
+ export declare const PLUGIN_REFRESH_TIMEOUT_MS = 15000;
8
+ export declare const PLUGIN_REFRESH_LEAD_MS = 30000;
9
+ export declare const PLUGIN_READY_INTERVAL_MS = 1000;
10
+ export type PluginContextEvent = Exclude<HostToPluginMessageType, "init">;
11
+ export type PluginContextListener<E extends PluginContextEvent> = (payload: HostToPluginPayloads[E]) => void;
12
+ export type PluginContextState = "connected" | "ended";
13
+ export interface PluginContext {
14
+ readonly instanceId: string;
15
+ readonly user: PluginUserContext;
16
+ readonly plugin: PluginInfoContext;
17
+ readonly entry: PluginEntryKind;
18
+ /** Bearer token access for callers bringing their own HTTP client. */
19
+ readonly session: PluginTokenProvider;
20
+ /** `fetch` helpers for the plugin's own API, relative to `plugin.apiBaseUrl`. */
21
+ readonly api: PluginApi;
22
+ state(): PluginContextState;
23
+ /** Every account the caller may use on this entry. Which one the plugin works in, if it has
24
+ * the notion at all, is the plugin's own state. */
25
+ accounts(): readonly PluginAccountContext[];
26
+ /** Capabilities on one account; empty when it is not in `accounts()`. */
27
+ permissionsFor(accountNumber: string): readonly PluginPermission[];
28
+ hasPermission(permission: PluginPermission, accountNumber: string): boolean;
29
+ entryAvailable(): boolean;
30
+ theme(): PluginTheme;
31
+ /** Ask the host to refresh discovery. Sent automatically after a 403 unless disabled. */
32
+ requestAccessRefresh(reason?: PluginToHostPayloads["requestAccessRefresh"]["reason"]): void;
33
+ on<E extends PluginContextEvent>(event: E, listener: PluginContextListener<E>): () => void;
34
+ /** Stop listening and fail pending work. The host is not told; it unloads the frame itself. */
35
+ dispose(): void;
36
+ }
37
+ export interface ConnectOptions {
38
+ /** Defaults to `window.parent`. */
39
+ hostWindow?: PluginMessagePort;
40
+ /** Defaults to this document's origin: plugins are served from the portal's origin. */
41
+ hostOrigin?: string;
42
+ /** Defaults to `window`. */
43
+ listenOn?: PluginMessageListenerTarget;
44
+ /** Runs on `init` and every `themeChanged`. Default: `applyPluginTheme` on `document`; `false`
45
+ * leaves the document alone for plugins that theme themselves. */
46
+ applyTheme?: false | ((theme: PluginTheme) => void);
47
+ fetch?: FetchLike;
48
+ /** 0 disables. */
49
+ handshakeTimeoutMs?: number;
50
+ /** 0 disables. */
51
+ refreshTimeoutMs?: number;
52
+ /** Renew in the background once the token expires within this window. */
53
+ refreshLeadMs?: number;
54
+ accessRefreshOnForbidden?: boolean;
55
+ instanceId?: string;
56
+ }
57
+ /** Announce to the host and resolve once `init` arrives. Rejects with `handshake_timeout` (call
58
+ * again to retry), `incompatible_host`, `not_framed` or `invalid_options`. */
59
+ export declare function connectToInfraClient(options?: ConnectOptions): Promise<PluginContext>;