@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
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PLUGIN_ASSET_ROOT = exports.PLUGIN_API_ROOT = void 0;
4
+ exports.pluginApiBaseUrl = pluginApiBaseUrl;
5
+ exports.pluginAccountRoot = pluginAccountRoot;
6
+ exports.pluginRouteResourcePath = pluginRouteResourcePath;
7
+ exports.pluginAccountPath = pluginAccountPath;
8
+ exports.parsePluginResourcePath = parsePluginResourcePath;
9
+ exports.pluginAssetPrefix = pluginAssetPrefix;
10
+ exports.pluginUiUrl = pluginUiUrl;
11
+ const manifest_1 = require("./manifest");
12
+ exports.PLUGIN_API_ROOT = "/api/plugins";
13
+ exports.PLUGIN_ASSET_ROOT = "/plugin-assets";
14
+ function pluginApiBaseUrl(pluginId) {
15
+ return `${exports.PLUGIN_API_ROOT}/${pluginId}`;
16
+ }
17
+ /** The gateway resource every route of a plugin hangs off: `/api/plugins/<id>/accounts/{accountNumber}`. */
18
+ function pluginAccountRoot(pluginId) {
19
+ return `${pluginApiBaseUrl(pluginId)}/accounts/{${manifest_1.PLUGIN_ACCOUNT_PARAM}}`;
20
+ }
21
+ /** The gateway resource path template for one registered route. */
22
+ function pluginRouteResourcePath(pluginId, routePath) {
23
+ return pluginAccountRoot(pluginId) + (routePath === "/" ? "" : routePath);
24
+ }
25
+ /** The concrete account root a request lands on: the template with the account filled in. */
26
+ function pluginAccountPath(pluginId, accountNumber) {
27
+ return pluginAccountRoot(pluginId).replace(`{${manifest_1.PLUGIN_ACCOUNT_PARAM}}`, accountNumber);
28
+ }
29
+ /** The inverse of `pluginRouteResourcePath`: the authorizer resolves a gateway resource template
30
+ * back to the plugin and route it belongs to. `null` for anything that is not one. */
31
+ function parsePluginResourcePath(resource) {
32
+ const prefix = `${exports.PLUGIN_API_ROOT}/`;
33
+ if (!resource.startsWith(prefix))
34
+ return null;
35
+ const pluginId = resource.slice(prefix.length).split("/")[0];
36
+ if (!manifest_1.PLUGIN_ID_PATTERN.test(pluginId))
37
+ return null;
38
+ const root = pluginAccountRoot(pluginId);
39
+ if (resource === root)
40
+ return { pluginId, routePath: "/" };
41
+ return resource.startsWith(`${root}/`) ? { pluginId, routePath: resource.slice(root.length) } : null;
42
+ }
43
+ /** Immutable asset prefix: a new hash for changed bytes, even under the same version. */
44
+ function pluginAssetPrefix(pluginId, version, assetHash) {
45
+ return `${exports.PLUGIN_ASSET_ROOT}/${pluginId}/${version}-${assetHash}`;
46
+ }
47
+ function pluginUiUrl(assetPrefix, entry) {
48
+ return `${assetPrefix}/${entry}`;
49
+ }
@@ -0,0 +1,21 @@
1
+ /** The documented subset of the portal's semantic CSS custom properties handed to plugin frames. */
2
+ export declare const PLUGIN_THEME_TOKENS: readonly ["--background", "--surface", "--surface-foreground", "--muted", "--muted-foreground", "--text-secondary", "--placeholder", "--border", "--border-strong", "--ring", "--card-bg", "--popover-bg", "--state-hover", "--state-selected", "--gray-1", "--gray-2", "--gray-3", "--gray-4", "--gray-5", "--gray-6", "--gray-7", "--gray-8", "--gray-9", "--gray-10", "--gray-11", "--gray-12", "--accent-1", "--accent-2", "--accent-3", "--accent-4", "--accent-5", "--accent-6", "--accent-7", "--accent-8", "--accent-9", "--accent-10", "--accent-11", "--accent-12", "--brand-primary", "--brand-primary-hover", "--brand-foreground", "--brand-text", "--brand-muted-foreground", "--success", "--success-muted", "--warning", "--warning-muted", "--danger", "--danger-hover", "--danger-muted", "--radius", "--radius-md", "--radius-lg", "--radius-xl", "--font-sans", "--font-mono", "--ease-standard", "--ease-emphasized"];
3
+ export type PluginThemeToken = (typeof PLUGIN_THEME_TOKENS)[number];
4
+ export type PluginThemeTokens = Partial<Record<PluginThemeToken, string>>;
5
+ export type PluginAppearance = "light" | "dark";
6
+ export interface PluginBranding {
7
+ readonly projectName: string;
8
+ readonly logoUrl?: string;
9
+ }
10
+ export interface PluginTheme {
11
+ readonly appearance: PluginAppearance;
12
+ readonly tokens: PluginThemeTokens;
13
+ readonly branding: PluginBranding;
14
+ }
15
+ export declare function isPluginThemeToken(name: string): name is PluginThemeToken;
16
+ export declare function isPluginTheme(value: unknown): value is PluginTheme;
17
+ /** A token value is a color, length, font stack or easing; anything that could close the rule or
18
+ * pull a resource is dropped rather than written into the plugin document. */
19
+ export declare function isSafeCssValue(value: string): boolean;
20
+ /** A stylesheet that applies the host theme to a plugin document. Unknown tokens are ignored. */
21
+ export declare function pluginThemeCss(theme: PluginTheme, selector?: string): string;
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PLUGIN_THEME_TOKENS = void 0;
4
+ exports.isPluginThemeToken = isPluginThemeToken;
5
+ exports.isPluginTheme = isPluginTheme;
6
+ exports.isSafeCssValue = isSafeCssValue;
7
+ exports.pluginThemeCss = pluginThemeCss;
8
+ /** The documented subset of the portal's semantic CSS custom properties handed to plugin frames. */
9
+ exports.PLUGIN_THEME_TOKENS = [
10
+ "--background", "--surface", "--surface-foreground", "--muted", "--muted-foreground",
11
+ "--text-secondary", "--placeholder", "--border", "--border-strong", "--ring",
12
+ "--card-bg", "--popover-bg", "--state-hover", "--state-selected",
13
+ "--gray-1", "--gray-2", "--gray-3", "--gray-4", "--gray-5", "--gray-6",
14
+ "--gray-7", "--gray-8", "--gray-9", "--gray-10", "--gray-11", "--gray-12",
15
+ "--accent-1", "--accent-2", "--accent-3", "--accent-4", "--accent-5", "--accent-6",
16
+ "--accent-7", "--accent-8", "--accent-9", "--accent-10", "--accent-11", "--accent-12",
17
+ "--brand-primary", "--brand-primary-hover", "--brand-foreground", "--brand-text", "--brand-muted-foreground",
18
+ "--success", "--success-muted", "--warning", "--warning-muted",
19
+ "--danger", "--danger-hover", "--danger-muted",
20
+ "--radius", "--radius-md", "--radius-lg", "--radius-xl",
21
+ "--font-sans", "--font-mono", "--ease-standard", "--ease-emphasized",
22
+ ];
23
+ const TOKEN_SET = new Set(exports.PLUGIN_THEME_TOKENS);
24
+ function isPluginThemeToken(name) {
25
+ return TOKEN_SET.has(name);
26
+ }
27
+ function isPluginTheme(value) {
28
+ if (!value || typeof value !== "object")
29
+ return false;
30
+ const t = value;
31
+ if (t.appearance !== "light" && t.appearance !== "dark")
32
+ return false;
33
+ if (!t.tokens || typeof t.tokens !== "object" || Array.isArray(t.tokens))
34
+ return false;
35
+ for (const [k, v] of Object.entries(t.tokens)) {
36
+ if (!isPluginThemeToken(k) || typeof v !== "string")
37
+ return false;
38
+ }
39
+ const b = t.branding;
40
+ if (!b || typeof b !== "object" || typeof b.projectName !== "string")
41
+ return false;
42
+ if (b.logoUrl !== undefined && typeof b.logoUrl !== "string")
43
+ return false;
44
+ return true;
45
+ }
46
+ /** A token value is a color, length, font stack or easing; anything that could close the rule or
47
+ * pull a resource is dropped rather than written into the plugin document. */
48
+ function isSafeCssValue(value) {
49
+ return value.length > 0 && value.length <= 200 && !/[;{}<>]/.test(value) && !/url\s*\(|expression\s*\(|@import/i.test(value);
50
+ }
51
+ /** A stylesheet that applies the host theme to a plugin document. Unknown tokens are ignored. */
52
+ function pluginThemeCss(theme, selector = ":root") {
53
+ const lines = [`color-scheme: ${theme.appearance};`];
54
+ for (const name of exports.PLUGIN_THEME_TOKENS) {
55
+ const value = theme.tokens[name];
56
+ if (value !== undefined && isSafeCssValue(value))
57
+ lines.push(`${name}: ${value};`);
58
+ }
59
+ return `${selector} {\n ${lines.join("\n ")}\n}\n`;
60
+ }
@@ -0,0 +1,14 @@
1
+ export interface PluginMessagePort {
2
+ postMessage(message: unknown, targetOrigin: string): void;
3
+ }
4
+ export interface PluginMessageEventLike {
5
+ readonly data: unknown;
6
+ readonly origin: string;
7
+ readonly source: unknown;
8
+ }
9
+ export type PluginMessageListener = (event: PluginMessageEventLike) => void;
10
+ export interface PluginMessageListenerTarget {
11
+ addEventListener(type: "message", listener: PluginMessageListener): void;
12
+ removeEventListener(type: "message", listener: PluginMessageListener): void;
13
+ }
14
+ export declare function newPluginProtocolId(prefix: string): string;
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ // The slice of `Window` the iframe protocol touches, typed structurally so tests and the local
3
+ // development host can supply fakes. A real `Window` satisfies all three.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.newPluginProtocolId = newPluginProtocolId;
6
+ function newPluginProtocolId(prefix) {
7
+ const c = globalThis.crypto;
8
+ const id = typeof c?.randomUUID === "function" ? c.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
9
+ return `${prefix}-${id}`;
10
+ }
@@ -0,0 +1,27 @@
1
+ import { PluginManifestV1 } from "./manifest";
2
+ export type PluginValidationCode = "not-an-object" | "unknown-field" | "schema-version" | "host-api-version" | "plugin-id" | "display-name" | "description" | "version" | "enabled" | "accounts-empty" | "account-number" | "account-duplicate" | "region" | "region-duplicate" | "permissions-empty" | "permission-id" | "permission-duplicate" | "permission-label" | "ui-empty" | "ui-unknown-entry" | "ui-undeclared-permission" | "ui-entry-path" | "ui-label" | "ui-icon" | "ui-order" | "ui-assets-directory" | "ui-entry-missing" | "route-id" | "route-duplicate-id" | "route-method" | "route-path" | "route-greedy" | "route-reserved-param" | "route-param-duplicate" | "route-param-conflict" | "route-duplicate-shape" | "route-undeclared-permission" | "route-handler-key" | "no-surface" | "manifest-too-large";
3
+ export interface PluginValidationIssue {
4
+ readonly code: PluginValidationCode;
5
+ /** Dotted location in the manifest, "" for the whole document. */
6
+ readonly path: string;
7
+ readonly message: string;
8
+ }
9
+ export type PluginValidationResult = {
10
+ readonly ok: true;
11
+ readonly manifest: PluginManifestV1;
12
+ } | {
13
+ readonly ok: false;
14
+ readonly issues: readonly PluginValidationIssue[];
15
+ };
16
+ export interface ValidatePluginManifestOptions {
17
+ /** When given, every route's handlerKey must be one of these. */
18
+ readonly handlerKeys?: readonly string[];
19
+ }
20
+ export declare class PluginValidationError extends Error {
21
+ readonly issues: readonly PluginValidationIssue[];
22
+ constructor(issues: readonly PluginValidationIssue[], subject?: string);
23
+ }
24
+ export declare function assertPluginManifest(input: unknown, options?: ValidatePluginManifestOptions): PluginManifestV1;
25
+ export declare function validatePluginManifest(input: unknown, options?: ValidatePluginManifestOptions): PluginValidationResult;
26
+ /** The file part of an entry ("index.html#/user" → "index.html"). */
27
+ export declare function pluginEntryFile(entry: string): string;
@@ -0,0 +1,321 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PluginValidationError = void 0;
4
+ exports.assertPluginManifest = assertPluginManifest;
5
+ exports.validatePluginManifest = validatePluginManifest;
6
+ exports.pluginEntryFile = pluginEntryFile;
7
+ const manifest_1 = require("./manifest");
8
+ class PluginValidationError extends Error {
9
+ issues;
10
+ constructor(issues, subject = "Plugin manifest") {
11
+ super(`${subject} is invalid:\n` +
12
+ issues.map((i) => ` - ${i.path || "(manifest)"}: ${i.message} [${i.code}]`).join("\n"));
13
+ this.issues = issues;
14
+ this.name = "PluginValidationError";
15
+ }
16
+ }
17
+ exports.PluginValidationError = PluginValidationError;
18
+ function assertPluginManifest(input, options) {
19
+ const result = validatePluginManifest(input, options);
20
+ if (!result.ok)
21
+ throw new PluginValidationError(result.issues);
22
+ return result.manifest;
23
+ }
24
+ const TOP_LEVEL_KEYS = ["schemaVersion", "id", "displayName", "description", "version", "hostApiVersion", "enabled", "accounts", "permissions", "ui", "routes"];
25
+ const ACCOUNT_KEYS = ["accountNumber", "regions"];
26
+ const PERMISSION_KEYS = ["id", "label", "description"];
27
+ const UI_KEYS = ["user", "admin"];
28
+ const UI_ENTRY_KEYS = ["label", "icon", "order", "entry"];
29
+ const ROUTE_KEYS = ["id", "method", "path", "permission", "handlerKey"];
30
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
31
+ const isText = (v, max) => typeof v === "string" && v.trim().length > 0 && v.length <= max;
32
+ function unknownKeys(obj, known, at, code, issue) {
33
+ for (const key of Object.keys(obj)) {
34
+ if (!known.includes(key))
35
+ issue(code, at ? `${at}.${key}` : key, `unknown field "${key}"`);
36
+ }
37
+ }
38
+ function validatePluginManifest(input, options = {}) {
39
+ const issues = [];
40
+ const issue = (code, path, message) => { issues.push({ code, path, message }); };
41
+ if (!isRecord(input)) {
42
+ return { ok: false, issues: [{ code: "not-an-object", path: "", message: "manifest must be an object" }] };
43
+ }
44
+ const m = input;
45
+ unknownKeys(m, TOP_LEVEL_KEYS, "", "unknown-field", issue);
46
+ if (m.schemaVersion !== manifest_1.PLUGIN_MANIFEST_SCHEMA_VERSION)
47
+ issue("schema-version", "schemaVersion", `must be ${manifest_1.PLUGIN_MANIFEST_SCHEMA_VERSION}`);
48
+ if (m.hostApiVersion !== manifest_1.PLUGIN_HOST_API_VERSION)
49
+ issue("host-api-version", "hostApiVersion", `must be ${manifest_1.PLUGIN_HOST_API_VERSION}`);
50
+ if (typeof m.id !== "string" || !manifest_1.PLUGIN_ID_PATTERN.test(m.id))
51
+ issue("plugin-id", "id", "lowercase letters, digits and hyphens, starting with a letter, 1-64 characters");
52
+ if (!isText(m.displayName, manifest_1.PLUGIN_DISPLAY_NAME_MAX))
53
+ issue("display-name", "displayName", `1-${manifest_1.PLUGIN_DISPLAY_NAME_MAX} characters`);
54
+ if (m.description !== undefined && (typeof m.description !== "string" || m.description.length > manifest_1.PLUGIN_DESCRIPTION_MAX))
55
+ issue("description", "description", `a string of at most ${manifest_1.PLUGIN_DESCRIPTION_MAX} characters`);
56
+ if (typeof m.version !== "string" || !manifest_1.PLUGIN_VERSION_PATTERN.test(m.version))
57
+ issue("version", "version", "letters, digits, dots, plus and hyphens, 1-64 characters (it becomes part of the asset URL)");
58
+ if (typeof m.enabled !== "boolean")
59
+ issue("enabled", "enabled", "must be true or false");
60
+ validateAccounts(m.accounts, issue);
61
+ const declared = validatePermissions(m.permissions, issue);
62
+ const uiEntries = m.ui === undefined ? 0 : validateUi(m.ui, declared, issue);
63
+ const routeCount = validateRoutes(m.routes, declared, options.handlerKeys, issue);
64
+ if (uiEntries === 0 && routeCount === 0)
65
+ issue("no-surface", "", "declare at least one UI entry or one route");
66
+ if (issues.length === 0) {
67
+ const bytes = new TextEncoder().encode(JSON.stringify(m)).length;
68
+ if (bytes > manifest_1.PLUGIN_MANIFEST_MAX_BYTES)
69
+ issue("manifest-too-large", "", `${bytes} bytes; the limit is ${manifest_1.PLUGIN_MANIFEST_MAX_BYTES}`);
70
+ }
71
+ if (issues.length > 0)
72
+ return { ok: false, issues };
73
+ return { ok: true, manifest: JSON.parse(JSON.stringify(m)) };
74
+ }
75
+ function validateAccounts(accounts, issue) {
76
+ if (!Array.isArray(accounts) || accounts.length === 0) {
77
+ issue("accounts-empty", "accounts", "bind the plugin to at least one AWS account");
78
+ return;
79
+ }
80
+ const seen = new Set();
81
+ accounts.forEach((a, i) => {
82
+ const at = `accounts[${i}]`;
83
+ if (!isRecord(a)) {
84
+ issue("account-number", at, "must be an object with accountNumber");
85
+ return;
86
+ }
87
+ unknownKeys(a, ACCOUNT_KEYS, at, "unknown-field", issue);
88
+ if (typeof a.accountNumber !== "string" || !manifest_1.AWS_ACCOUNT_PATTERN.test(a.accountNumber)) {
89
+ issue("account-number", `${at}.accountNumber`, "must be a 12-digit AWS account id");
90
+ }
91
+ else if (seen.has(a.accountNumber)) {
92
+ issue("account-duplicate", `${at}.accountNumber`, `account ${a.accountNumber} is bound twice`);
93
+ }
94
+ else {
95
+ seen.add(a.accountNumber);
96
+ }
97
+ if (a.regions !== undefined) {
98
+ if (!Array.isArray(a.regions) || a.regions.length === 0) {
99
+ issue("region", `${at}.regions`, "omit regions or list at least one");
100
+ }
101
+ else {
102
+ const regs = new Set();
103
+ a.regions.forEach((r, j) => {
104
+ if (typeof r !== "string" || !manifest_1.AWS_REGION_PATTERN.test(r))
105
+ issue("region", `${at}.regions[${j}]`, `"${String(r)}" is not an AWS region name`);
106
+ else if (regs.has(r))
107
+ issue("region-duplicate", `${at}.regions[${j}]`, `${r} is listed twice`);
108
+ else
109
+ regs.add(r);
110
+ });
111
+ }
112
+ }
113
+ });
114
+ }
115
+ function validatePermissions(permissions, issue) {
116
+ const declared = [];
117
+ if (!Array.isArray(permissions) || permissions.length === 0) {
118
+ issue("permissions-empty", "permissions", `declare "user", "admin", or both`);
119
+ return declared;
120
+ }
121
+ permissions.forEach((p, i) => {
122
+ const at = `permissions[${i}]`;
123
+ if (!isRecord(p)) {
124
+ issue("permission-id", at, "must be an object with id and label");
125
+ return;
126
+ }
127
+ unknownKeys(p, PERMISSION_KEYS, at, "unknown-field", issue);
128
+ if (typeof p.id !== "string" || !manifest_1.PLUGIN_PERMISSIONS.includes(p.id)) {
129
+ issue("permission-id", `${at}.id`, `must be "user" or "admin"`);
130
+ }
131
+ else if (declared.includes(p.id)) {
132
+ issue("permission-duplicate", `${at}.id`, `"${p.id}" is declared twice`);
133
+ }
134
+ else {
135
+ declared.push(p.id);
136
+ }
137
+ if (!isText(p.label, manifest_1.PLUGIN_DISPLAY_NAME_MAX))
138
+ issue("permission-label", `${at}.label`, `1-${manifest_1.PLUGIN_DISPLAY_NAME_MAX} characters`);
139
+ if (p.description !== undefined && (typeof p.description !== "string" || p.description.length > manifest_1.PLUGIN_DESCRIPTION_MAX))
140
+ issue("description", `${at}.description`, `a string of at most ${manifest_1.PLUGIN_DESCRIPTION_MAX} characters`);
141
+ });
142
+ return declared;
143
+ }
144
+ function validateUi(ui, declared, issue) {
145
+ if (!isRecord(ui)) {
146
+ issue("ui-unknown-entry", "ui", "must be an object with user and/or admin entries");
147
+ return 0;
148
+ }
149
+ unknownKeys(ui, UI_KEYS, "ui", "ui-unknown-entry", issue);
150
+ let count = 0;
151
+ for (const kind of UI_KEYS) {
152
+ const entry = ui[kind];
153
+ if (entry === undefined)
154
+ continue;
155
+ count++;
156
+ const at = `ui.${kind}`;
157
+ if (!isRecord(entry)) {
158
+ issue("ui-entry-path", at, "must be an object with label and entry");
159
+ continue;
160
+ }
161
+ unknownKeys(entry, UI_ENTRY_KEYS, at, "unknown-field", issue);
162
+ if (!declared.includes(kind))
163
+ issue("ui-undeclared-permission", at, `a ${kind} entry needs the "${kind}" permission declared`);
164
+ if (!isText(entry.label, manifest_1.PLUGIN_DISPLAY_NAME_MAX))
165
+ issue("ui-label", `${at}.label`, `1-${manifest_1.PLUGIN_DISPLAY_NAME_MAX} characters`);
166
+ if (entry.icon !== undefined && (typeof entry.icon !== "string" || !manifest_1.PLUGIN_ICON_PATTERN.test(entry.icon)))
167
+ issue("ui-icon", `${at}.icon`, "a host icon identifier: lowercase letters, digits, hyphens");
168
+ if (entry.order !== undefined && !Number.isInteger(entry.order))
169
+ issue("ui-order", `${at}.order`, "must be an integer");
170
+ validateEntryPath(entry.entry, `${at}.entry`, issue);
171
+ }
172
+ if (count === 0)
173
+ issue("ui-empty", "ui", "omit ui or give it a user and/or admin entry");
174
+ return count;
175
+ }
176
+ /** The file part of an entry ("index.html#/user" → "index.html"). */
177
+ function pluginEntryFile(entry) {
178
+ const hash = entry.indexOf("#");
179
+ return hash === -1 ? entry : entry.slice(0, hash);
180
+ }
181
+ function validateEntryPath(entry, at, issue) {
182
+ if (typeof entry !== "string" || entry.length === 0) {
183
+ issue("ui-entry-path", at, `required: a file inside the asset directory, e.g. "index.html#/user"`);
184
+ return;
185
+ }
186
+ const file = pluginEntryFile(entry);
187
+ if (file === "")
188
+ issue("ui-entry-path", at, `must name a file before the "#"`);
189
+ else if (/^[a-z][a-z0-9+.-]*:/i.test(file) || file.startsWith("//"))
190
+ issue("ui-entry-path", at, "must be relative to the asset directory, not a URL");
191
+ else if (file.startsWith("/"))
192
+ issue("ui-entry-path", at, `must not start with "/"`);
193
+ else if (file.includes("\\") || file.includes("?"))
194
+ issue("ui-entry-path", at, `must not contain "\\" or "?"`);
195
+ else if (file.split("/").some((s) => s === "" || s === "." || s === ".."))
196
+ issue("ui-entry-path", at, `must not contain empty, "." or ".." segments`);
197
+ else if (!/^[A-Za-z0-9._~/-]+$/.test(file))
198
+ issue("ui-entry-path", at, "letters, digits, ., _, ~, - and / only");
199
+ }
200
+ function parseRoutePath(path, at, issue) {
201
+ if (typeof path !== "string" || !path.startsWith("/")) {
202
+ issue("route-path", at, `must start with "/"`);
203
+ return;
204
+ }
205
+ if (path === "/")
206
+ return { segments: [], shape: "/" };
207
+ if (path.endsWith("/")) {
208
+ issue("route-path", at, `must not end with "/"`);
209
+ return;
210
+ }
211
+ const segments = [];
212
+ const names = new Set();
213
+ let bad = false;
214
+ for (const part of path.slice(1).split("/")) {
215
+ if (part === "") {
216
+ issue("route-path", at, "empty segment");
217
+ bad = true;
218
+ continue;
219
+ }
220
+ const param = /^\{([A-Za-z][A-Za-z0-9]*)\}$/.exec(part);
221
+ if (param) {
222
+ const name = param[1];
223
+ if (name === manifest_1.PLUGIN_ACCOUNT_PARAM) {
224
+ issue("route-reserved-param", at, `{${manifest_1.PLUGIN_ACCOUNT_PARAM}} is the platform's account segment`);
225
+ bad = true;
226
+ }
227
+ if (names.has(name)) {
228
+ issue("route-param-duplicate", at, `{${name}} appears twice`);
229
+ bad = true;
230
+ }
231
+ names.add(name);
232
+ segments.push({ kind: "param", name });
233
+ continue;
234
+ }
235
+ if (part.includes("+") || part === "*") {
236
+ issue("route-greedy", at, `greedy segments like "{proxy+}" are not supported`);
237
+ bad = true;
238
+ continue;
239
+ }
240
+ if (part.startsWith("{")) {
241
+ issue("route-path", at, `malformed parameter "${part}": use {name} with letters and digits`);
242
+ bad = true;
243
+ continue;
244
+ }
245
+ if (!/^[A-Za-z0-9._~-]+$/.test(part)) {
246
+ issue("route-path", at, `segment "${part}" has characters outside letters, digits, ., _, ~, -`);
247
+ bad = true;
248
+ continue;
249
+ }
250
+ segments.push({ kind: "literal", value: part });
251
+ }
252
+ if (bad)
253
+ return;
254
+ return { segments, shape: "/" + segments.map((s) => (s.kind === "param" ? "{}" : s.value)).join("/") };
255
+ }
256
+ function validateRoutes(routes, declared, handlerKeys, issue) {
257
+ if (routes === undefined) {
258
+ issue("route-path", "routes", "required: an array (empty for a UI-only plugin)");
259
+ return 0;
260
+ }
261
+ if (!Array.isArray(routes)) {
262
+ issue("route-path", "routes", "must be an array");
263
+ return 0;
264
+ }
265
+ const ids = new Set();
266
+ const shapes = new Map();
267
+ const paramAt = new Map();
268
+ routes.forEach((r, i) => {
269
+ const at = `routes[${i}]`;
270
+ if (!isRecord(r)) {
271
+ issue("route-id", at, "must be an object with id, method, path, permission, handlerKey");
272
+ return;
273
+ }
274
+ unknownKeys(r, ROUTE_KEYS, at, "unknown-field", issue);
275
+ const routeId = typeof r.id === "string" ? r.id : `#${i}`;
276
+ if (typeof r.id !== "string" || !manifest_1.PLUGIN_ID_PATTERN.test(r.id))
277
+ issue("route-id", `${at}.id`, "lowercase letters, digits and hyphens, starting with a letter, 1-64 characters");
278
+ else if (ids.has(r.id))
279
+ issue("route-duplicate-id", `${at}.id`, `route id "${r.id}" is used twice`);
280
+ else
281
+ ids.add(r.id);
282
+ const method = typeof r.method === "string" && manifest_1.PLUGIN_ROUTE_METHODS.includes(r.method) ? r.method : undefined;
283
+ if (!method)
284
+ issue("route-method", `${at}.method`, `one of ${manifest_1.PLUGIN_ROUTE_METHODS.join(", ")} (no ANY)`);
285
+ if (typeof r.permission !== "string" || !manifest_1.PLUGIN_PERMISSIONS.includes(r.permission))
286
+ issue("route-undeclared-permission", `${at}.permission`, `must be "user" or "admin"`);
287
+ else if (!declared.includes(r.permission))
288
+ issue("route-undeclared-permission", `${at}.permission`, `"${r.permission}" is not declared in permissions`);
289
+ if (typeof r.handlerKey !== "string" || r.handlerKey.length === 0)
290
+ issue("route-handler-key", `${at}.handlerKey`, "required: the key of a backend handler");
291
+ else if (handlerKeys && !handlerKeys.includes(r.handlerKey))
292
+ issue("route-handler-key", `${at}.handlerKey`, `no handler named "${r.handlerKey}" (have: ${handlerKeys.join(", ") || "none"})`);
293
+ const parsed = parseRoutePath(r.path, `${at}.path`, issue);
294
+ if (!parsed || !method)
295
+ return;
296
+ const shapeKey = `${method} ${parsed.shape}`;
297
+ const dup = shapes.get(shapeKey);
298
+ if (dup)
299
+ issue("route-duplicate-shape", `${at}.path`, `${method} ${parsed.shape} is already registered by route "${dup}" (parameter names do not distinguish routes)`);
300
+ else
301
+ shapes.set(shapeKey, routeId);
302
+ let prefix = "";
303
+ for (const seg of parsed.segments) {
304
+ if (seg.kind === "param") {
305
+ const key = `${prefix}/{}`;
306
+ const prev = paramAt.get(key);
307
+ if (prev && prev.name !== seg.name) {
308
+ issue("route-param-conflict", `${at}.path`, `{${seg.name}} conflicts with {${prev.name}} in route "${prev.routeId}": API Gateway allows one parameter name per position`);
309
+ }
310
+ else if (!prev) {
311
+ paramAt.set(key, { name: seg.name, routeId });
312
+ }
313
+ prefix = key;
314
+ }
315
+ else {
316
+ prefix = `${prefix}/${seg.value}`;
317
+ }
318
+ }
319
+ });
320
+ return routes.length;
321
+ }
@@ -0,0 +1,57 @@
1
+ import { PluginAccountContext, PluginInfoContext, PluginSession, PluginUserContext } from "../contracts/messages";
2
+ import { PluginAppearance, PluginTheme } from "../contracts/theme";
3
+ import { PluginMessageListenerTarget } from "../contracts/transport";
4
+ import { PluginHost, PluginHostFrame, PluginSessionEndReason } from "../host/host";
5
+ export type DevHostEvent = {
6
+ readonly type: "ready";
7
+ readonly instanceId: string;
8
+ readonly sdkVersion: string;
9
+ readonly reconnect: boolean;
10
+ } | {
11
+ readonly type: "handshakeTimeout";
12
+ } | {
13
+ readonly type: "refresh";
14
+ readonly outcome: "renewed" | "failed";
15
+ } | {
16
+ readonly type: "accessRefresh";
17
+ readonly reason: "forbidden" | "manual";
18
+ } | {
19
+ readonly type: "incompatible";
20
+ readonly version: unknown;
21
+ } | {
22
+ readonly type: "ended";
23
+ readonly reason: PluginSessionEndReason;
24
+ };
25
+ export interface DevHostOptions {
26
+ readonly frame: PluginHostFrame;
27
+ readonly listenOn: PluginMessageListenerTarget;
28
+ readonly origin: string;
29
+ readonly plugin?: Partial<PluginInfoContext>;
30
+ readonly entry?: "user" | "admin";
31
+ readonly user?: Partial<PluginUserContext>;
32
+ readonly accounts?: readonly PluginAccountContext[];
33
+ readonly appearance?: PluginAppearance;
34
+ readonly theme?: PluginTheme;
35
+ /** How long a fixture token lives; default five minutes. */
36
+ readonly tokenLifetimeMs?: number;
37
+ readonly handshakeTimeoutMs?: number;
38
+ readonly onEvent?: (event: DevHostEvent) => void;
39
+ }
40
+ export interface DevHost extends PluginHost {
41
+ session(): PluginSession;
42
+ accounts(): readonly PluginAccountContext[];
43
+ /** Mint a new fixture token and push it, as the portal's renew timer would. */
44
+ renewSession(): PluginSession;
45
+ /** Push the current token with an expiry in the past: the plugin's next request asks to renew. */
46
+ expireSession(): void;
47
+ /** The next renewal request ends the session instead (the portal's 12 h wall). */
48
+ failNextRefresh(): void;
49
+ grant(account: PluginAccountContext): void;
50
+ revoke(accountNumber: string): void;
51
+ setEntryAvailable(available: boolean): void;
52
+ setAppearance(appearance: PluginAppearance): void;
53
+ setTheme(theme: PluginTheme): void;
54
+ signOut(): void;
55
+ }
56
+ export declare function devHostTheme(appearance: PluginAppearance): PluginTheme;
57
+ export declare function createDevHost(options: DevHostOptions): DevHost;
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.devHostTheme = devHostTheme;
4
+ exports.createDevHost = createDevHost;
5
+ const paths_1 = require("../contracts/paths");
6
+ const host_1 = require("../host/host");
7
+ const samples_1 = require("./samples");
8
+ function devHostTheme(appearance) {
9
+ return appearance === "dark" ? samples_1.sampleDarkTheme : samples_1.sampleTheme;
10
+ }
11
+ function createDevHost(options) {
12
+ const lifetime = options.tokenLifetimeMs ?? 5 * 60_000;
13
+ let sequence = 0;
14
+ const mint = () => ({ token: `fixture-token-${++sequence}`, expiresAt: Date.now() + lifetime });
15
+ let session = mint();
16
+ let accounts = options.accounts ?? samples_1.sampleAccounts;
17
+ let entryAvailable = true;
18
+ let theme = options.theme ?? devHostTheme(options.appearance ?? "light");
19
+ let failNext = false;
20
+ const emit = (event) => options.onEvent?.(event);
21
+ const base = {
22
+ id: "parameters", displayName: "Application parameters", version: "0.0.0-dev", revision: "dev",
23
+ ...options.plugin,
24
+ };
25
+ // Derived AFTER the overrides: a developer who renames the plugin gets their own API root.
26
+ const plugin = { ...base, apiBaseUrl: options.plugin?.apiBaseUrl ?? (0, paths_1.pluginApiBaseUrl)(base.id) };
27
+ const user = { subject: "dev-user", principalType: "user", email: "dev@example.com", displayName: "Dev User", ...options.user };
28
+ const renew = () => {
29
+ session = mint();
30
+ return session;
31
+ };
32
+ const host = (0, host_1.createPluginHost)({
33
+ frame: options.frame,
34
+ origin: options.origin,
35
+ listenOn: options.listenOn,
36
+ handshakeTimeoutMs: options.handshakeTimeoutMs,
37
+ initialState: () => ({ session, user, plugin, entry: options.entry ?? "user", accounts, theme }),
38
+ refreshSession: async () => {
39
+ if (failNext) {
40
+ failNext = false;
41
+ emit({ type: "refresh", outcome: "failed" });
42
+ return null;
43
+ }
44
+ emit({ type: "refresh", outcome: "renewed" });
45
+ return renew();
46
+ },
47
+ onReady: (info) => emit({ type: "ready", ...info }),
48
+ onHandshakeTimeout: () => emit({ type: "handshakeTimeout" }),
49
+ onRequestAccessRefresh: (reason) => emit({ type: "accessRefresh", reason }),
50
+ onIncompatible: (info) => emit({ type: "incompatible", version: info.version }),
51
+ onEnded: (reason) => emit({ type: "ended", reason }),
52
+ });
53
+ const pushAccess = () => host.sendAccess({ accounts, entryAvailable });
54
+ return {
55
+ ...host,
56
+ session: () => session,
57
+ accounts: () => accounts,
58
+ renewSession: () => {
59
+ host.sendSession(renew());
60
+ return session;
61
+ },
62
+ expireSession: () => {
63
+ session = { token: session.token, expiresAt: Date.now() - 1 };
64
+ host.sendSession(session);
65
+ },
66
+ failNextRefresh: () => {
67
+ failNext = true;
68
+ },
69
+ grant: (account) => {
70
+ accounts = [...accounts.filter((a) => a.accountNumber !== account.accountNumber), account];
71
+ pushAccess();
72
+ },
73
+ revoke: (accountNumber) => {
74
+ accounts = accounts.filter((a) => a.accountNumber !== accountNumber);
75
+ pushAccess();
76
+ },
77
+ setEntryAvailable: (available) => {
78
+ entryAvailable = available;
79
+ pushAccess();
80
+ },
81
+ setAppearance: (appearance) => {
82
+ theme = devHostTheme(appearance);
83
+ host.sendTheme(theme);
84
+ },
85
+ setTheme: (next) => {
86
+ theme = next;
87
+ host.sendTheme(next);
88
+ },
89
+ signOut: () => host.end("signedOut"),
90
+ };
91
+ }