@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.
- package/LICENSE +93 -0
- package/README.md +279 -2
- package/dist/backend/context.d.ts +31 -0
- package/dist/backend/context.js +58 -0
- package/dist/backend/index.d.ts +9 -0
- package/dist/backend/index.js +26 -0
- package/dist/browser/client.d.ts +59 -0
- package/dist/browser/client.js +288 -0
- package/dist/browser/errors.d.ts +14 -0
- package/dist/browser/errors.js +32 -0
- package/dist/browser/http.d.ts +72 -0
- package/dist/browser/http.js +170 -0
- package/dist/browser/index.d.ts +11 -0
- package/dist/browser/index.js +28 -0
- package/dist/browser/theme-apply.d.ts +22 -0
- package/dist/browser/theme-apply.js +22 -0
- package/dist/contracts/access.d.ts +14 -0
- package/dist/contracts/access.js +41 -0
- package/dist/contracts/authorizer.d.ts +17 -0
- package/dist/contracts/authorizer.js +44 -0
- package/dist/contracts/canonical.d.ts +2 -0
- package/dist/contracts/canonical.js +21 -0
- package/dist/contracts/catalog.d.ts +37 -0
- package/dist/contracts/catalog.js +18 -0
- package/dist/contracts/discovery.d.ts +78 -0
- package/dist/contracts/discovery.js +48 -0
- package/dist/contracts/fields.d.ts +7 -0
- package/dist/contracts/fields.js +2 -0
- package/dist/contracts/manifest.d.ts +59 -0
- package/dist/contracts/manifest.js +19 -0
- package/dist/contracts/messages.d.ts +108 -0
- package/dist/contracts/messages.js +103 -0
- package/dist/contracts/paths.d.ts +20 -0
- package/dist/contracts/paths.js +49 -0
- package/dist/contracts/theme.d.ts +21 -0
- package/dist/contracts/theme.js +60 -0
- package/dist/contracts/transport.d.ts +14 -0
- package/dist/contracts/transport.js +10 -0
- package/dist/contracts/validate.d.ts +27 -0
- package/dist/contracts/validate.js +321 -0
- package/dist/fixtures/devHost.d.ts +57 -0
- package/dist/fixtures/devHost.js +91 -0
- package/dist/fixtures/fakeDocument.d.ts +12 -0
- package/dist/fixtures/fakeDocument.js +22 -0
- package/dist/fixtures/fakeWindows.d.ts +22 -0
- package/dist/fixtures/fakeWindows.js +46 -0
- package/dist/fixtures/index.d.ts +4 -0
- package/dist/fixtures/index.js +20 -0
- package/dist/fixtures/samples.d.ts +37 -0
- package/dist/fixtures/samples.js +220 -0
- package/dist/host/host.d.ts +47 -0
- package/dist/host/host.js +117 -0
- package/dist/host/index.d.ts +5 -0
- package/dist/host/index.js +22 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +28 -0
- package/package.json +97 -4
- 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
|
-
#
|
|
1
|
+
# @fntio/infra-client-plugin-sdk
|
|
2
2
|
|
|
3
|
-
|
|
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>;
|