@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
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { PluginThemeDocument, PluginThemeStyleElement } from "../browser/theme-apply";
|
|
2
|
+
export interface FakeThemeDocument {
|
|
3
|
+
readonly doc: PluginThemeDocument;
|
|
4
|
+
/** Attributes set on the root element (`data-theme`). */
|
|
5
|
+
readonly attributes: Record<string, string>;
|
|
6
|
+
/** Style elements by id; `applyPluginTheme` rewrites the same one in place. */
|
|
7
|
+
readonly styles: Map<string, PluginThemeStyleElement>;
|
|
8
|
+
/** Ids in append order, to prove nothing is appended twice. */
|
|
9
|
+
readonly appended: string[];
|
|
10
|
+
}
|
|
11
|
+
/** The slice of `document` the SDK themes, for tests of plugins and of the SDK itself. */
|
|
12
|
+
export declare function createFakeThemeDocument(): FakeThemeDocument;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createFakeThemeDocument = createFakeThemeDocument;
|
|
4
|
+
/** The slice of `document` the SDK themes, for tests of plugins and of the SDK itself. */
|
|
5
|
+
function createFakeThemeDocument() {
|
|
6
|
+
const attributes = {};
|
|
7
|
+
const styles = new Map();
|
|
8
|
+
const appended = [];
|
|
9
|
+
const doc = {
|
|
10
|
+
documentElement: { setAttribute: (name, value) => void (attributes[name] = value) },
|
|
11
|
+
head: {
|
|
12
|
+
appendChild: (node) => {
|
|
13
|
+
const element = node;
|
|
14
|
+
appended.push(element.id);
|
|
15
|
+
styles.set(element.id, element);
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
getElementById: (id) => styles.get(id) ?? null,
|
|
19
|
+
createElement: () => ({ id: "", textContent: null }),
|
|
20
|
+
};
|
|
21
|
+
return { doc, attributes, styles, appended };
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { PluginMessageEventLike, PluginMessageListenerTarget, PluginMessagePort } from "../contracts/transport";
|
|
2
|
+
export interface FakeWindow extends PluginMessagePort, PluginMessageListenerTarget {
|
|
3
|
+
readonly origin: string;
|
|
4
|
+
/** Everything posted TO this window (`window.postMessage` is called on the target), oldest
|
|
5
|
+
* first, with the origin the caller addressed. */
|
|
6
|
+
readonly inbox: ReadonlyArray<{
|
|
7
|
+
readonly message: unknown;
|
|
8
|
+
readonly targetOrigin: string;
|
|
9
|
+
}>;
|
|
10
|
+
/** Deliver an event straight to this window's listeners, bypassing the peer (spoofing tests). */
|
|
11
|
+
receive(event: PluginMessageEventLike): void;
|
|
12
|
+
}
|
|
13
|
+
export interface FakeWindowPair {
|
|
14
|
+
readonly host: FakeWindow;
|
|
15
|
+
readonly plugin: FakeWindow;
|
|
16
|
+
}
|
|
17
|
+
/** Two windows wired like a portal page and its plugin iframe. Browser semantics: calling
|
|
18
|
+
* `postMessage` on a window delivers to that window's listeners, on a microtask, with the OTHER
|
|
19
|
+
* window as `source`; a `targetOrigin` other than `*` or the target's own origin drops the message. */
|
|
20
|
+
export declare function createFakeWindowPair(hostOrigin?: string, pluginOrigin?: string): FakeWindowPair;
|
|
21
|
+
/** Let queued deliveries and the promise chains behind them run. */
|
|
22
|
+
export declare function settle(): Promise<void>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createFakeWindowPair = createFakeWindowPair;
|
|
4
|
+
exports.settle = settle;
|
|
5
|
+
// jest-environment-jsdom 29 has no structuredClone; JSON is close enough for protocol payloads.
|
|
6
|
+
const clone = typeof structuredClone === "function" ? structuredClone : (value) => JSON.parse(JSON.stringify(value));
|
|
7
|
+
/** Two windows wired like a portal page and its plugin iframe. Browser semantics: calling
|
|
8
|
+
* `postMessage` on a window delivers to that window's listeners, on a microtask, with the OTHER
|
|
9
|
+
* window as `source`; a `targetOrigin` other than `*` or the target's own origin drops the message. */
|
|
10
|
+
function createFakeWindowPair(hostOrigin = "https://portal.example", pluginOrigin = hostOrigin) {
|
|
11
|
+
const make = (origin) => {
|
|
12
|
+
const listeners = new Set();
|
|
13
|
+
const inbox = [];
|
|
14
|
+
let peer;
|
|
15
|
+
const self = {
|
|
16
|
+
origin,
|
|
17
|
+
inbox,
|
|
18
|
+
link: (other) => (peer = other),
|
|
19
|
+
postMessage(message, targetOrigin) {
|
|
20
|
+
inbox.push({ message, targetOrigin });
|
|
21
|
+
const data = clone(message);
|
|
22
|
+
const source = peer;
|
|
23
|
+
if (!source || (targetOrigin !== "*" && targetOrigin !== origin))
|
|
24
|
+
return;
|
|
25
|
+
// A promise microtask, not queueMicrotask: fake timers stub the latter and would stall delivery.
|
|
26
|
+
void Promise.resolve().then(() => self.receive({ data, origin: source.origin, source }));
|
|
27
|
+
},
|
|
28
|
+
addEventListener: (_type, listener) => void listeners.add(listener),
|
|
29
|
+
removeEventListener: (_type, listener) => void listeners.delete(listener),
|
|
30
|
+
receive: (event) => {
|
|
31
|
+
for (const listener of [...listeners])
|
|
32
|
+
listener(event);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
return self;
|
|
36
|
+
};
|
|
37
|
+
const host = make(hostOrigin);
|
|
38
|
+
const plugin = make(pluginOrigin);
|
|
39
|
+
host.link(plugin);
|
|
40
|
+
plugin.link(host);
|
|
41
|
+
return { host, plugin };
|
|
42
|
+
}
|
|
43
|
+
/** Let queued deliveries and the promise chains behind them run. */
|
|
44
|
+
async function settle() {
|
|
45
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
46
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
__exportStar(require("./samples"), exports);
|
|
18
|
+
__exportStar(require("./fakeWindows"), exports);
|
|
19
|
+
__exportStar(require("./fakeDocument"), exports);
|
|
20
|
+
__exportStar(require("./devHost"), exports);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { PluginAuthorizerContextV1 } from "../contracts/authorizer";
|
|
2
|
+
import { PluginCatalogRecordV1 } from "../contracts/catalog";
|
|
3
|
+
import { PluginDiscoveryResponseV1 } from "../contracts/discovery";
|
|
4
|
+
import { PluginManifestV1 } from "../contracts/manifest";
|
|
5
|
+
import { HostToPluginPayloads, PluginAccountContext } from "../contracts/messages";
|
|
6
|
+
import { PluginTheme } from "../contracts/theme";
|
|
7
|
+
import { PluginValidationCode } from "../contracts/validate";
|
|
8
|
+
export declare const FIXTURE_ACCOUNT_A = "111111111111";
|
|
9
|
+
export declare const FIXTURE_ACCOUNT_B = "222222222222";
|
|
10
|
+
/** The reference plugin: user + admin, two UI entries, three routes on one handler. */
|
|
11
|
+
export declare const parametersManifest: PluginManifestV1;
|
|
12
|
+
export declare const PARAMETERS_HANDLER_KEYS: readonly string[];
|
|
13
|
+
export declare const uiOnlyManifest: PluginManifestV1;
|
|
14
|
+
export declare const backendOnlyManifest: PluginManifestV1;
|
|
15
|
+
export declare const adminOnlyUiManifest: PluginManifestV1;
|
|
16
|
+
export declare const disabledManifest: PluginManifestV1;
|
|
17
|
+
export interface ValidManifestFixture {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly manifest: PluginManifestV1;
|
|
20
|
+
readonly handlerKeys?: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
export declare const validManifests: readonly ValidManifestFixture[];
|
|
23
|
+
export interface InvalidManifestFixture {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly manifest: unknown;
|
|
26
|
+
/** The code the validator must report (it may report others too). */
|
|
27
|
+
readonly code: PluginValidationCode;
|
|
28
|
+
readonly handlerKeys?: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
export declare const invalidManifests: readonly InvalidManifestFixture[];
|
|
31
|
+
export declare const sampleTheme: PluginTheme;
|
|
32
|
+
export declare const sampleDarkTheme: PluginTheme;
|
|
33
|
+
export declare const sampleCatalogRecord: PluginCatalogRecordV1;
|
|
34
|
+
export declare const sampleAccounts: readonly PluginAccountContext[];
|
|
35
|
+
export declare const sampleDiscoveryResponse: PluginDiscoveryResponseV1;
|
|
36
|
+
export declare const sampleInitPayload: HostToPluginPayloads["init"];
|
|
37
|
+
export declare const sampleAuthorizerContext: PluginAuthorizerContextV1;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sampleAuthorizerContext = exports.sampleInitPayload = exports.sampleDiscoveryResponse = exports.sampleAccounts = exports.sampleCatalogRecord = exports.sampleDarkTheme = exports.sampleTheme = exports.invalidManifests = exports.validManifests = exports.disabledManifest = exports.adminOnlyUiManifest = exports.backendOnlyManifest = exports.uiOnlyManifest = exports.PARAMETERS_HANDLER_KEYS = exports.parametersManifest = exports.FIXTURE_ACCOUNT_B = exports.FIXTURE_ACCOUNT_A = void 0;
|
|
4
|
+
const paths_1 = require("../contracts/paths");
|
|
5
|
+
exports.FIXTURE_ACCOUNT_A = "111111111111";
|
|
6
|
+
exports.FIXTURE_ACCOUNT_B = "222222222222";
|
|
7
|
+
/** The reference plugin: user + admin, two UI entries, three routes on one handler. */
|
|
8
|
+
exports.parametersManifest = {
|
|
9
|
+
schemaVersion: 1,
|
|
10
|
+
id: "parameters",
|
|
11
|
+
displayName: "Application parameters",
|
|
12
|
+
description: "Read and edit SSM parameters under the configured prefixes.",
|
|
13
|
+
version: "1.0.0",
|
|
14
|
+
hostApiVersion: 1,
|
|
15
|
+
enabled: true,
|
|
16
|
+
accounts: [{ accountNumber: exports.FIXTURE_ACCOUNT_A, regions: ["eu-central-1"] }, { accountNumber: exports.FIXTURE_ACCOUNT_B }],
|
|
17
|
+
permissions: [
|
|
18
|
+
{ id: "user", label: "View application parameters" },
|
|
19
|
+
{ id: "admin", label: "Edit application parameters", description: "Includes viewing." },
|
|
20
|
+
],
|
|
21
|
+
ui: {
|
|
22
|
+
user: { label: "Parameters", icon: "sliders", order: 10, entry: "index.html#/user" },
|
|
23
|
+
admin: { label: "Parameters", icon: "sliders", order: 10, entry: "index.html#/admin" },
|
|
24
|
+
},
|
|
25
|
+
routes: [
|
|
26
|
+
{ id: "list", method: "GET", path: "/parameters", permission: "user", handlerKey: "api" },
|
|
27
|
+
{ id: "get", method: "GET", path: "/parameters/{name}", permission: "user", handlerKey: "api" },
|
|
28
|
+
{ id: "update", method: "PUT", path: "/parameters/{name}", permission: "admin", handlerKey: "api" },
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
exports.PARAMETERS_HANDLER_KEYS = ["api"];
|
|
32
|
+
exports.uiOnlyManifest = {
|
|
33
|
+
schemaVersion: 1,
|
|
34
|
+
id: "status-board",
|
|
35
|
+
displayName: "Status board",
|
|
36
|
+
version: "2.3.1",
|
|
37
|
+
hostApiVersion: 1,
|
|
38
|
+
enabled: true,
|
|
39
|
+
accounts: [{ accountNumber: exports.FIXTURE_ACCOUNT_A }],
|
|
40
|
+
permissions: [{ id: "user", label: "View the status board" }],
|
|
41
|
+
ui: { user: { label: "Status", entry: "board.html" } },
|
|
42
|
+
routes: [],
|
|
43
|
+
};
|
|
44
|
+
exports.backendOnlyManifest = {
|
|
45
|
+
schemaVersion: 1,
|
|
46
|
+
id: "backup-jobs",
|
|
47
|
+
displayName: "Database backups",
|
|
48
|
+
version: "0.4.0",
|
|
49
|
+
hostApiVersion: 1,
|
|
50
|
+
enabled: true,
|
|
51
|
+
accounts: [{ accountNumber: exports.FIXTURE_ACCOUNT_B, regions: ["eu-central-1", "eu-west-1"] }],
|
|
52
|
+
permissions: [{ id: "admin", label: "Run and inspect backups" }],
|
|
53
|
+
routes: [
|
|
54
|
+
{ id: "start", method: "POST", path: "/jobs", permission: "admin", handlerKey: "jobs" },
|
|
55
|
+
{ id: "status", method: "GET", path: "/jobs/{jobId}", permission: "admin", handlerKey: "jobs" },
|
|
56
|
+
{ id: "cancel", method: "DELETE", path: "/jobs/{jobId}", permission: "admin", handlerKey: "jobs" },
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
exports.adminOnlyUiManifest = {
|
|
60
|
+
schemaVersion: 1,
|
|
61
|
+
id: "feature-flags",
|
|
62
|
+
displayName: "Feature flags",
|
|
63
|
+
version: "1.0.0",
|
|
64
|
+
hostApiVersion: 1,
|
|
65
|
+
enabled: true,
|
|
66
|
+
accounts: [{ accountNumber: exports.FIXTURE_ACCOUNT_A }],
|
|
67
|
+
permissions: [{ id: "admin", label: "Manage feature flags" }],
|
|
68
|
+
ui: { admin: { label: "Feature flags", entry: "index.html" } },
|
|
69
|
+
routes: [{ id: "root", method: "GET", path: "/", permission: "admin", handlerKey: "flags" }],
|
|
70
|
+
};
|
|
71
|
+
exports.disabledManifest = { ...exports.parametersManifest, id: "parameters-legacy", enabled: false };
|
|
72
|
+
exports.validManifests = [
|
|
73
|
+
{ name: "user + admin with UI and routes", manifest: exports.parametersManifest, handlerKeys: exports.PARAMETERS_HANDLER_KEYS },
|
|
74
|
+
{ name: "UI-only", manifest: exports.uiOnlyManifest, handlerKeys: [] },
|
|
75
|
+
{ name: "backend-only", manifest: exports.backendOnlyManifest, handlerKeys: ["jobs"] },
|
|
76
|
+
{ name: "admin-only with a root route", manifest: exports.adminOnlyUiManifest, handlerKeys: ["flags"] },
|
|
77
|
+
{ name: "disabled", manifest: exports.disabledManifest, handlerKeys: exports.PARAMETERS_HANDLER_KEYS },
|
|
78
|
+
];
|
|
79
|
+
const patch = (p) => ({ ...exports.parametersManifest, ...p });
|
|
80
|
+
const route = (o) => ({
|
|
81
|
+
id: "r", method: "GET", path: "/x", permission: "user", handlerKey: "api", ...o,
|
|
82
|
+
});
|
|
83
|
+
const userUi = { user: exports.parametersManifest.ui.user };
|
|
84
|
+
exports.invalidManifests = [
|
|
85
|
+
{ name: "not an object", manifest: "parameters", code: "not-an-object" },
|
|
86
|
+
{ name: "unknown top-level field", manifest: patch({ handler: "api" }), code: "unknown-field" },
|
|
87
|
+
{ name: "schemaVersion 2", manifest: patch({ schemaVersion: 2 }), code: "schema-version" },
|
|
88
|
+
{ name: "hostApiVersion 2", manifest: patch({ hostApiVersion: 2 }), code: "host-api-version" },
|
|
89
|
+
{ name: "id with uppercase", manifest: patch({ id: "Parameters" }), code: "plugin-id" },
|
|
90
|
+
{ name: "id starting with a digit", manifest: patch({ id: "1parameters" }), code: "plugin-id" },
|
|
91
|
+
{ name: "id too long", manifest: patch({ id: "a".repeat(65) }), code: "plugin-id" },
|
|
92
|
+
{ name: "blank displayName", manifest: patch({ displayName: " " }), code: "display-name" },
|
|
93
|
+
{ name: "version with a space", manifest: patch({ version: "1.0 beta" }), code: "version" },
|
|
94
|
+
{ name: "enabled as a string", manifest: patch({ enabled: "yes" }), code: "enabled" },
|
|
95
|
+
{ name: "no accounts", manifest: patch({ accounts: [] }), code: "accounts-empty" },
|
|
96
|
+
{ name: "short account number", manifest: patch({ accounts: [{ accountNumber: "12345" }] }), code: "account-number" },
|
|
97
|
+
{ name: "account bound twice", manifest: patch({ accounts: [{ accountNumber: exports.FIXTURE_ACCOUNT_A }, { accountNumber: exports.FIXTURE_ACCOUNT_A }] }), code: "account-duplicate" },
|
|
98
|
+
{ name: "invalid region", manifest: patch({ accounts: [{ accountNumber: exports.FIXTURE_ACCOUNT_A, regions: ["europe"] }] }), code: "region" },
|
|
99
|
+
{ name: "region listed twice", manifest: patch({ accounts: [{ accountNumber: exports.FIXTURE_ACCOUNT_A, regions: ["eu-central-1", "eu-central-1"] }] }), code: "region-duplicate" },
|
|
100
|
+
{ name: "no permissions", manifest: patch({ permissions: [] }), code: "permissions-empty" },
|
|
101
|
+
{ name: "unknown permission id", manifest: patch({ permissions: [{ id: "owner", label: "Owner" }] }), code: "permission-id" },
|
|
102
|
+
{ name: "permission declared twice", manifest: patch({ permissions: [{ id: "user", label: "A" }, { id: "user", label: "B" }] }), code: "permission-duplicate" },
|
|
103
|
+
{ name: "permission without a label", manifest: patch({ permissions: [{ id: "user", label: "" }, { id: "admin", label: "Edit" }] }), code: "permission-label" },
|
|
104
|
+
{ name: "empty ui object", manifest: patch({ ui: {} }), code: "ui-empty" },
|
|
105
|
+
{ name: "a third UI entry", manifest: patch({ ui: { ...exports.parametersManifest.ui, reports: { label: "Reports", entry: "index.html#/reports" } } }), code: "ui-unknown-entry" },
|
|
106
|
+
{ name: "admin entry without the admin permission", manifest: patch({ permissions: [{ id: "user", label: "View" }], routes: [route({})], ui: { admin: { label: "Admin", entry: "index.html#/admin" } } }), code: "ui-undeclared-permission" },
|
|
107
|
+
{ name: "absolute entry path", manifest: patch({ ui: { user: { label: "P", entry: "/index.html" } } }), code: "ui-entry-path" },
|
|
108
|
+
{ name: "entry is a URL", manifest: patch({ ui: { user: { label: "P", entry: "https://example.com/index.html" } } }), code: "ui-entry-path" },
|
|
109
|
+
{ name: "entry escapes the asset directory", manifest: patch({ ui: { user: { label: "P", entry: "../index.html" } } }), code: "ui-entry-path" },
|
|
110
|
+
{ name: "entry without a file before the hash", manifest: patch({ ui: { user: { label: "P", entry: "#/user" } } }), code: "ui-entry-path" },
|
|
111
|
+
{ name: "icon with spaces", manifest: patch({ ui: { user: { label: "P", icon: "my icon", entry: "index.html" } } }), code: "ui-icon" },
|
|
112
|
+
{ name: "order not an integer", manifest: patch({ ui: { user: { label: "P", order: 1.5, entry: "index.html" } } }), code: "ui-order" },
|
|
113
|
+
{ name: "route id used twice", manifest: patch({ routes: [route({ id: "list", path: "/a" }), route({ id: "list", path: "/b" })] }), code: "route-duplicate-id" },
|
|
114
|
+
{ name: "route method ANY", manifest: patch({ routes: [route({ method: "ANY" })] }), code: "route-method" },
|
|
115
|
+
{ name: "route path without a leading slash", manifest: patch({ routes: [route({ path: "parameters" })] }), code: "route-path" },
|
|
116
|
+
{ name: "route path with a trailing slash", manifest: patch({ routes: [route({ path: "/parameters/" })] }), code: "route-path" },
|
|
117
|
+
{ name: "greedy proxy route", manifest: patch({ routes: [route({ path: "/{proxy+}" })] }), code: "route-greedy" },
|
|
118
|
+
{ name: "route declares the account parameter", manifest: patch({ routes: [route({ path: "/{accountNumber}/x" })] }), code: "route-reserved-param" },
|
|
119
|
+
{ name: "same parameter twice in one path", manifest: patch({ routes: [route({ path: "/a/{id}/b/{id}" })] }), code: "route-param-duplicate" },
|
|
120
|
+
{ name: "parameter name conflict at one position", manifest: patch({ routes: [route({ id: "a", path: "/items/{id}" }), route({ id: "b", path: "/items/{name}/history" })] }), code: "route-param-conflict" },
|
|
121
|
+
{ name: "same shape with different parameter names", manifest: patch({ routes: [route({ id: "a", path: "/items/{id}" }), route({ id: "b", path: "/items/{key}" })] }), code: "route-duplicate-shape" },
|
|
122
|
+
{ name: "same method and path twice", manifest: patch({ routes: [route({ id: "a" }), route({ id: "b" })] }), code: "route-duplicate-shape" },
|
|
123
|
+
{ name: "route requires an undeclared permission", manifest: patch({ permissions: [{ id: "user", label: "View" }], ui: userUi, routes: [route({ permission: "admin" })] }), code: "route-undeclared-permission" },
|
|
124
|
+
{ name: "route with an unknown handler key", manifest: patch({ routes: [route({ handlerKey: "missing" })] }), handlerKeys: ["api"], code: "route-handler-key" },
|
|
125
|
+
{ name: "neither UI nor routes", manifest: patch({ ui: undefined, routes: [] }), code: "no-surface" },
|
|
126
|
+
{ name: "routes missing entirely", manifest: patch({ routes: undefined }), code: "route-path" },
|
|
127
|
+
{
|
|
128
|
+
name: "manifest over the size limit",
|
|
129
|
+
manifest: patch({ routes: Array.from({ length: 1400 }, (_, i) => route({ id: `route-${i}`, path: `/resource-${i}/items` })) }),
|
|
130
|
+
code: "manifest-too-large",
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
exports.sampleTheme = {
|
|
134
|
+
appearance: "light",
|
|
135
|
+
tokens: {
|
|
136
|
+
"--background": "#ffffff",
|
|
137
|
+
"--surface": "#f7f7f8",
|
|
138
|
+
"--surface-foreground": "#1b1f26",
|
|
139
|
+
"--muted-foreground": "#6d7583",
|
|
140
|
+
"--border": "rgba(27, 31, 38, 0.12)",
|
|
141
|
+
"--accent-9": "#ea580c",
|
|
142
|
+
"--brand-primary": "#ea580c",
|
|
143
|
+
"--brand-foreground": "#ffffff",
|
|
144
|
+
"--radius": "0.5rem",
|
|
145
|
+
"--font-sans": "\"Inter\", system-ui, sans-serif",
|
|
146
|
+
},
|
|
147
|
+
branding: { projectName: "Example Portal" },
|
|
148
|
+
};
|
|
149
|
+
exports.sampleDarkTheme = {
|
|
150
|
+
...exports.sampleTheme,
|
|
151
|
+
appearance: "dark",
|
|
152
|
+
tokens: {
|
|
153
|
+
...exports.sampleTheme.tokens,
|
|
154
|
+
"--background": "#111318",
|
|
155
|
+
"--surface": "#1b1f26",
|
|
156
|
+
"--surface-foreground": "#e6e8ec",
|
|
157
|
+
"--muted-foreground": "#9aa3b2",
|
|
158
|
+
"--border": "rgba(230, 232, 236, 0.14)",
|
|
159
|
+
"--accent-9": "#f97316",
|
|
160
|
+
"--brand-primary": "#f97316",
|
|
161
|
+
"--brand-foreground": "#111318",
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
const ASSET_PREFIX = (0, paths_1.pluginAssetPrefix)("parameters", "1.0.0", "0123abcd");
|
|
165
|
+
exports.sampleCatalogRecord = {
|
|
166
|
+
pluginId: "parameters",
|
|
167
|
+
schemaVersion: 1,
|
|
168
|
+
manifest: exports.parametersManifest,
|
|
169
|
+
revision: "0123abcd0123abcd",
|
|
170
|
+
assetPrefix: ASSET_PREFIX,
|
|
171
|
+
installed: true,
|
|
172
|
+
owner: "WebStack/parameters",
|
|
173
|
+
ui: {
|
|
174
|
+
user: { label: "Parameters", icon: "sliders", order: 10, url: (0, paths_1.pluginUiUrl)(ASSET_PREFIX, "index.html#/user") },
|
|
175
|
+
admin: { label: "Parameters", icon: "sliders", order: 10, url: (0, paths_1.pluginUiUrl)(ASSET_PREFIX, "index.html#/admin") },
|
|
176
|
+
},
|
|
177
|
+
routes: exports.parametersManifest.routes.map((r) => ({ ...r, resourcePath: (0, paths_1.pluginRouteResourcePath)("parameters", r.path) })),
|
|
178
|
+
installedAt: "2026-09-16T09:00:00.000Z",
|
|
179
|
+
updatedAt: "2026-09-16T09:00:00.000Z",
|
|
180
|
+
};
|
|
181
|
+
exports.sampleAccounts = [
|
|
182
|
+
{ accountNumber: exports.FIXTURE_ACCOUNT_A, displayName: "Example workloads", regions: ["eu-central-1"], permissions: ["user", "admin"] },
|
|
183
|
+
{ accountNumber: exports.FIXTURE_ACCOUNT_B, displayName: "Example sandbox", permissions: ["user"] },
|
|
184
|
+
];
|
|
185
|
+
exports.sampleDiscoveryResponse = {
|
|
186
|
+
schemaVersion: 1,
|
|
187
|
+
refreshAfterSeconds: 60,
|
|
188
|
+
plugins: [
|
|
189
|
+
{
|
|
190
|
+
id: "parameters",
|
|
191
|
+
displayName: "Application parameters",
|
|
192
|
+
description: exports.parametersManifest.description,
|
|
193
|
+
version: "1.0.0",
|
|
194
|
+
revision: "0123abcd0123abcd",
|
|
195
|
+
apiBaseUrl: "/api/plugins/parameters",
|
|
196
|
+
ui: { user: { label: "Parameters", icon: "sliders", order: 10, url: (0, paths_1.pluginUiUrl)(ASSET_PREFIX, "index.html#/user") } },
|
|
197
|
+
accounts: [{ accountNumber: exports.FIXTURE_ACCOUNT_A, displayName: "Example workloads", regions: ["eu-central-1"], permissions: ["user"] }],
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
exports.sampleInitPayload = {
|
|
202
|
+
// Relative, not a timestamp: a fixed one expired on 2026-09-21 and every suite built on this
|
|
203
|
+
// fixture (ours and any plugin author's) started asking the host for an "expiring" refresh.
|
|
204
|
+
session: { token: "eyJhbGciOiJSUzI1NiJ9.example.signature", expiresAt: Date.now() + 3_600_000 },
|
|
205
|
+
user: { subject: "6f1e2d3c-0000-4000-8000-000000000001", principalType: "user", email: "admin@example.com", displayName: "Example Admin" },
|
|
206
|
+
plugin: { id: "parameters", displayName: "Application parameters", version: "1.0.0", revision: "0123abcd0123abcd", apiBaseUrl: "/api/plugins/parameters" },
|
|
207
|
+
entry: "user",
|
|
208
|
+
accounts: exports.sampleAccounts,
|
|
209
|
+
theme: exports.sampleTheme,
|
|
210
|
+
};
|
|
211
|
+
exports.sampleAuthorizerContext = {
|
|
212
|
+
pluginId: "parameters",
|
|
213
|
+
pluginRevision: "0123abcd0123abcd",
|
|
214
|
+
routeId: "list",
|
|
215
|
+
accountNumber: exports.FIXTURE_ACCOUNT_A,
|
|
216
|
+
requiredPermission: "user",
|
|
217
|
+
subject: "6f1e2d3c-0000-4000-8000-000000000001",
|
|
218
|
+
principalType: "user",
|
|
219
|
+
correlationId: "req-0001",
|
|
220
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { HostToPluginPayloads, PluginSession, PluginToHostPayloads } from "../contracts/messages";
|
|
2
|
+
import { PluginTheme } from "../contracts/theme";
|
|
3
|
+
import { PluginMessageListenerTarget, PluginMessagePort } from "../contracts/transport";
|
|
4
|
+
export type PluginHostInitState = HostToPluginPayloads["init"];
|
|
5
|
+
export type PluginSessionEndReason = HostToPluginPayloads["sessionEnded"]["reason"];
|
|
6
|
+
export type PluginHostState = "waiting" | "connected" | "ended";
|
|
7
|
+
/** An `HTMLIFrameElement` satisfies this. */
|
|
8
|
+
export interface PluginHostFrame {
|
|
9
|
+
readonly contentWindow: PluginMessagePort | null;
|
|
10
|
+
}
|
|
11
|
+
export interface PluginHostOptions {
|
|
12
|
+
readonly frame: PluginHostFrame;
|
|
13
|
+
/** The exact origin messages are sent to and required from. Plugins share the portal's origin. */
|
|
14
|
+
readonly origin: string;
|
|
15
|
+
readonly listenOn: PluginMessageListenerTarget;
|
|
16
|
+
/** Read on every `ready`, so a reloaded frame starts from the host's current state. */
|
|
17
|
+
readonly initialState: () => PluginHostInitState;
|
|
18
|
+
/** The host's own renewal; `null` (or a throw, sync or async) means the session is over. */
|
|
19
|
+
readonly refreshSession: () => Promise<PluginSession | null>;
|
|
20
|
+
/** 0 disables. */
|
|
21
|
+
readonly handshakeTimeoutMs?: number;
|
|
22
|
+
readonly onReady?: (info: {
|
|
23
|
+
instanceId: string;
|
|
24
|
+
sdkVersion: string;
|
|
25
|
+
reconnect: boolean;
|
|
26
|
+
}) => void;
|
|
27
|
+
readonly onHandshakeTimeout?: () => void;
|
|
28
|
+
readonly onRequestAccessRefresh?: (reason: PluginToHostPayloads["requestAccessRefresh"]["reason"]) => void;
|
|
29
|
+
/** A frame speaking another protocol version; it is never initialised. */
|
|
30
|
+
readonly onIncompatible?: (info: {
|
|
31
|
+
version: unknown;
|
|
32
|
+
}) => void;
|
|
33
|
+
/** Fires for every `end`/`dispose`, connected or not; discriminate on `reason`. */
|
|
34
|
+
readonly onEnded?: (reason: PluginSessionEndReason) => void;
|
|
35
|
+
}
|
|
36
|
+
export interface PluginHost {
|
|
37
|
+
state(): PluginHostState;
|
|
38
|
+
instanceId(): string | null;
|
|
39
|
+
sendSession(session: PluginSession): void;
|
|
40
|
+
sendAccess(payload: HostToPluginPayloads["accessChanged"]): void;
|
|
41
|
+
sendTheme(theme: PluginTheme): void;
|
|
42
|
+
/** Tell the frame the session is over and stop listening. Terminal. */
|
|
43
|
+
end(reason: PluginSessionEndReason): void;
|
|
44
|
+
/** `end("unloaded")` unless already ended. Call from the host's own teardown. */
|
|
45
|
+
dispose(): void;
|
|
46
|
+
}
|
|
47
|
+
export declare function createPluginHost(options: PluginHostOptions): PluginHost;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createPluginHost = createPluginHost;
|
|
4
|
+
const messages_1 = require("../contracts/messages");
|
|
5
|
+
function createPluginHost(options) {
|
|
6
|
+
let state = "waiting";
|
|
7
|
+
let current = null;
|
|
8
|
+
let refreshing = null;
|
|
9
|
+
/** The frame the in-flight renewal belongs to, and the latest request awaiting its one answer. */
|
|
10
|
+
let refreshFor = null;
|
|
11
|
+
let replyTo;
|
|
12
|
+
let replyPending = false;
|
|
13
|
+
const timeoutMs = options.handshakeTimeoutMs ?? messages_1.PLUGIN_HANDSHAKE_TIMEOUT_MS;
|
|
14
|
+
const handshakeTimer = timeoutMs > 0
|
|
15
|
+
? setTimeout(() => {
|
|
16
|
+
if (state === "waiting")
|
|
17
|
+
options.onHandshakeTimeout?.();
|
|
18
|
+
}, timeoutMs)
|
|
19
|
+
: undefined;
|
|
20
|
+
const post = (type, payload, requestId) => {
|
|
21
|
+
const target = options.frame.contentWindow;
|
|
22
|
+
if (state !== "connected" || !current || !target)
|
|
23
|
+
return;
|
|
24
|
+
target.postMessage((0, messages_1.createPluginMessage)(type, current, payload, requestId), options.origin);
|
|
25
|
+
};
|
|
26
|
+
const end = (reason) => {
|
|
27
|
+
if (state === "ended")
|
|
28
|
+
return;
|
|
29
|
+
post("sessionEnded", { reason });
|
|
30
|
+
state = "ended";
|
|
31
|
+
replyPending = false;
|
|
32
|
+
clearTimeout(handshakeTimer);
|
|
33
|
+
options.listenOn.removeEventListener("message", listener);
|
|
34
|
+
options.onEnded?.(reason);
|
|
35
|
+
};
|
|
36
|
+
const onReady = (message) => {
|
|
37
|
+
const duplicate = current === message.instanceId;
|
|
38
|
+
const reconnect = current !== null && !duplicate;
|
|
39
|
+
if (reconnect)
|
|
40
|
+
replyPending = false; // the new frame never asked for the old one's renewal
|
|
41
|
+
current = message.instanceId;
|
|
42
|
+
state = "connected";
|
|
43
|
+
clearTimeout(handshakeTimer);
|
|
44
|
+
post("init", options.initialState());
|
|
45
|
+
if (!duplicate)
|
|
46
|
+
options.onReady?.({ instanceId: current, sdkVersion: message.payload.sdkVersion, reconnect });
|
|
47
|
+
};
|
|
48
|
+
/** One renewal at a time, one answer per renewal: a frame that asks repeatedly while it runs
|
|
49
|
+
* gets a single `sessionChanged`, and a frame replaced mid-flight gets nothing (its answer must
|
|
50
|
+
* not end the session of the frame that took its place). */
|
|
51
|
+
const refresh = (instanceId, requestId) => {
|
|
52
|
+
replyPending = true;
|
|
53
|
+
replyTo = requestId;
|
|
54
|
+
if (refreshing)
|
|
55
|
+
return;
|
|
56
|
+
refreshFor = instanceId;
|
|
57
|
+
refreshing = Promise.resolve()
|
|
58
|
+
.then(() => options.refreshSession())
|
|
59
|
+
.catch(() => null);
|
|
60
|
+
void refreshing.then((session) => {
|
|
61
|
+
refreshing = null;
|
|
62
|
+
const startedFor = refreshFor;
|
|
63
|
+
const answered = replyPending;
|
|
64
|
+
const id = replyTo;
|
|
65
|
+
refreshFor = null;
|
|
66
|
+
replyPending = false;
|
|
67
|
+
replyTo = undefined;
|
|
68
|
+
if (state === "ended" || !answered || startedFor !== current)
|
|
69
|
+
return;
|
|
70
|
+
if (!session) {
|
|
71
|
+
end("expired");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
post("sessionChanged", session, id);
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
const listener = (event) => {
|
|
78
|
+
if (state === "ended")
|
|
79
|
+
return;
|
|
80
|
+
if (event.source !== options.frame.contentWindow || event.origin !== options.origin)
|
|
81
|
+
return;
|
|
82
|
+
const seen = (0, messages_1.inspectPluginMessage)(event.data);
|
|
83
|
+
if (seen.kind === "incompatible") {
|
|
84
|
+
options.onIncompatible?.({ version: seen.version });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (seen.kind !== "message" || !(0, messages_1.isPluginToHostMessage)(seen.message))
|
|
88
|
+
return;
|
|
89
|
+
const message = seen.message;
|
|
90
|
+
if (message.type === "ready") {
|
|
91
|
+
onReady(message);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (message.instanceId !== current)
|
|
95
|
+
return; // a frame that was replaced or reloaded
|
|
96
|
+
switch (message.type) {
|
|
97
|
+
case "requestSessionRefresh":
|
|
98
|
+
refresh(message.instanceId, message.requestId);
|
|
99
|
+
return;
|
|
100
|
+
case "requestAccessRefresh":
|
|
101
|
+
options.onRequestAccessRefresh?.(message.payload.reason);
|
|
102
|
+
return;
|
|
103
|
+
default:
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
options.listenOn.addEventListener("message", listener);
|
|
108
|
+
return {
|
|
109
|
+
state: () => state,
|
|
110
|
+
instanceId: () => current,
|
|
111
|
+
sendSession: (session) => post("sessionChanged", session),
|
|
112
|
+
sendAccess: (payload) => post("accessChanged", payload),
|
|
113
|
+
sendTheme: (theme) => post("themeChanged", theme),
|
|
114
|
+
end,
|
|
115
|
+
dispose: () => end("unloaded"),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
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 the portal shell and the local development host: the host side of the iframe
|
|
18
|
+
// protocol. Browser code; must stay free of Node, CDK, AWS and Angular imports.
|
|
19
|
+
__exportStar(require("./host"), exports);
|
|
20
|
+
__exportStar(require("../contracts/messages"), exports);
|
|
21
|
+
__exportStar(require("../contracts/theme"), exports);
|
|
22
|
+
__exportStar(require("../contracts/transport"), exports);
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
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/theme";
|
|
9
|
+
export * from "./contracts/messages";
|
|
10
|
+
export * from "./contracts/authorizer";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
// The shared contract: what deployment (CDK), the core backend, the authorizer, the portal
|
|
18
|
+
// shell and plugin frames all agree on. Browser-safe: no Node, CDK or AWS imports here.
|
|
19
|
+
__exportStar(require("./contracts/manifest"), exports);
|
|
20
|
+
__exportStar(require("./contracts/validate"), exports);
|
|
21
|
+
__exportStar(require("./contracts/canonical"), exports);
|
|
22
|
+
__exportStar(require("./contracts/paths"), exports);
|
|
23
|
+
__exportStar(require("./contracts/access"), exports);
|
|
24
|
+
__exportStar(require("./contracts/catalog"), exports);
|
|
25
|
+
__exportStar(require("./contracts/discovery"), exports);
|
|
26
|
+
__exportStar(require("./contracts/theme"), exports);
|
|
27
|
+
__exportStar(require("./contracts/messages"), exports);
|
|
28
|
+
__exportStar(require("./contracts/authorizer"), exports);
|