@fntio/infra-client-plugin-example 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/src/handler.ts ADDED
@@ -0,0 +1,111 @@
1
+ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
2
+ import { SSMClient } from "@aws-sdk/client-ssm";
3
+ import {
4
+ createPluginLogger,
5
+ pluginAuthorizerContextOf,
6
+ pluginRoutePath,
7
+ PluginAuthorizerContextV1,
8
+ } from "@fntio/infra-client-plugin-sdk/backend";
9
+ import { ssmParameterStore } from "./parameters";
10
+ import { assertInScope, configuredPrefixes, ParameterScopeError, ParameterStore } from "./scope";
11
+
12
+ // The plugin's three routes. Authentication and authorization already happened: the shared authorizer
13
+ // verified the caller, matched this request to a registered route, and attached the context below. What
14
+ // is left is the plugin's own business — which is why nothing here re-reads a permission or an account
15
+ // from the request body.
16
+
17
+ const ssm = new SSMClient({});
18
+
19
+ /** The account this function may touch. A route authorized for account A must never act on account B,
20
+ * so a mismatch is refused rather than quietly served from wherever the function happens to run. */
21
+ const HOME_ACCOUNT = process.env.PLUGIN_ACCOUNT_NUMBER ?? "";
22
+
23
+ const json = (statusCode: number, body: unknown): APIGatewayProxyResult => ({
24
+ statusCode,
25
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
26
+ body: JSON.stringify(body),
27
+ });
28
+
29
+ export interface HandlerDeps {
30
+ readonly store: ParameterStore;
31
+ readonly prefixes: string[];
32
+ readonly homeAccount: string;
33
+ }
34
+
35
+ function deps(): HandlerDeps {
36
+ return { store: ssmParameterStore(ssm), prefixes: configuredPrefixes(), homeAccount: HOME_ACCOUNT };
37
+ }
38
+
39
+ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> {
40
+ return route(event, deps());
41
+ }
42
+
43
+ /** Exported for tests: the same path the Lambda takes, with its dependencies handed in. */
44
+ export async function route(event: APIGatewayProxyEvent, d: HandlerDeps): Promise<APIGatewayProxyResult> {
45
+ let ctx: PluginAuthorizerContextV1;
46
+ try {
47
+ ctx = pluginAuthorizerContextOf(event);
48
+ } catch {
49
+ // Only reachable if this function is invoked outside its gateway route; fail closed rather than
50
+ // guess who the caller is.
51
+ return json(500, { message: "This request did not come through the portal." });
52
+ }
53
+ const log = createPluginLogger(ctx);
54
+
55
+ if (!d.prefixes.length) {
56
+ log.error("no parameter prefixes configured");
57
+ return json(500, { message: "This plugin has no parameter prefixes configured." });
58
+ }
59
+ if (ctx.accountNumber !== d.homeAccount) {
60
+ // The portal authorized an account this deployment of the plugin cannot serve. Reading the home
61
+ // account's parameters anyway would hand the caller another account's data.
62
+ log.error("authorized for an account this plugin does not serve", { requested: ctx.accountNumber });
63
+ return json(409, { message: `This plugin instance only serves account ${d.homeAccount}.` });
64
+ }
65
+
66
+ const path = pluginRoutePath(event.path, ctx);
67
+ if (path === null) return json(400, { message: "That path is outside this plugin." });
68
+ const method = event.httpMethod.toUpperCase();
69
+ const name = event.pathParameters?.["name"];
70
+
71
+ try {
72
+ if (method === "GET" && path === "/parameters") {
73
+ const parameters = await d.store.list(d.prefixes);
74
+ log.info("listed parameters", { count: parameters.length });
75
+ return json(200, { parameters, prefixes: d.prefixes });
76
+ }
77
+
78
+ if (method === "GET" && name) {
79
+ const decoded = decodeURIComponent(name);
80
+ assertInScope(decoded, d.prefixes);
81
+ const parameter = await d.store.get(decoded);
82
+ log.info("read parameter", { name: decoded, secret: parameter.secret });
83
+ return json(200, { parameter });
84
+ }
85
+
86
+ if (method === "PUT" && name) {
87
+ const decoded = decodeURIComponent(name);
88
+ assertInScope(decoded, d.prefixes);
89
+ const value = (JSON.parse(event.body ?? "{}") as { value?: unknown }).value;
90
+ if (typeof value !== "string" || !value.length) {
91
+ return json(400, { message: "Send a non-empty string value." });
92
+ }
93
+ const version = await d.store.put(decoded, value);
94
+ // The value itself is never logged — only that it changed, and to which version.
95
+ log.info("updated parameter", { name: decoded, version });
96
+ return json(200, { name: decoded, version });
97
+ }
98
+ } catch (err) {
99
+ if (err instanceof ParameterScopeError) {
100
+ log.warn("refused an out-of-scope parameter", { error: err.message });
101
+ return json(400, { message: err.message });
102
+ }
103
+ if ((err as { name?: string }).name === "ParameterNotFound") {
104
+ return json(404, { message: "No such parameter." });
105
+ }
106
+ log.error("parameter operation failed", { error: (err as Error).message });
107
+ return json(502, { message: "The parameter store did not answer." });
108
+ }
109
+
110
+ return json(404, { message: `No such route: ${method} ${path}` });
111
+ }
package/src/index.ts ADDED
@@ -0,0 +1,96 @@
1
+ import * as path from "node:path";
2
+ import { Duration } from "aws-cdk-lib";
3
+ import * as iam from "aws-cdk-lib/aws-iam";
4
+ import * as lambda from "aws-cdk-lib/aws-lambda";
5
+ import type { Construct } from "constructs";
6
+ import { definePlugin, PluginDefinition } from "@fntio/infra-client-plugin-cdk";
7
+
8
+ export interface ParametersPluginProps {
9
+ /** The account whose parameters this instance serves. The handler refuses any other, even if the
10
+ * portal authorizes one, so a second account means a second registration with its own function. */
11
+ readonly accountNumber: string;
12
+ /** SSM name prefixes the plugin may read and write. Nothing outside them is reachable — not by a
13
+ * crafted name, not by a `..` segment. Least privilege is enforced twice: here and in the IAM policy. */
14
+ readonly prefixes: readonly string[];
15
+ /** Defaults to the stack's region. */
16
+ readonly region?: string;
17
+ readonly version?: string;
18
+ /** Off by default would hide it from everyone; registrations are usually enabled. */
19
+ readonly enabled?: boolean;
20
+ }
21
+
22
+ /** The reference plugin: a scoped Parameter Store viewer and editor.
23
+ *
24
+ * Everything a customer writes for their own plugin is here — a manifest, a handler factory, and the
25
+ * IAM the handler needs. `addAccessPortal({ plugins: [parametersPlugin(...)] })` is the only wiring. */
26
+ export function parametersPlugin(props: ParametersPluginProps): PluginDefinition {
27
+ const prefixes = props.prefixes.map((p) => (p.endsWith("/") ? p : `${p}/`));
28
+ if (!prefixes.length) throw new Error("parametersPlugin needs at least one parameter prefix");
29
+
30
+ return definePlugin({
31
+ schemaVersion: 1,
32
+ id: "parameters",
33
+ displayName: "Application parameters",
34
+ description: "Read and edit SSM parameters under the configured prefixes.",
35
+ version: props.version ?? "1.0.0",
36
+ hostApiVersion: 1,
37
+ enabled: props.enabled ?? true,
38
+ accounts: [{ accountNumber: props.accountNumber, ...(props.region ? { regions: [props.region] } : {}) }],
39
+ permissions: [
40
+ { id: "user", label: "View application parameters" },
41
+ { id: "admin", label: "Edit application parameters", description: "Includes viewing." },
42
+ ],
43
+ ui: {
44
+ assets: path.join(__dirname, "..", "web-dist"),
45
+ user: { label: "Parameters", icon: "sliders", order: 10, entry: "index.html#/user" },
46
+ admin: { label: "Parameters", icon: "sliders", order: 10, entry: "index.html#/admin" },
47
+ },
48
+ backend: {
49
+ handlers: { api: (scope) => apiFunction(scope, props.accountNumber, prefixes) },
50
+ routes: [
51
+ { id: "list", method: "GET", path: "/parameters", permission: "user", handlerKey: "api" },
52
+ { id: "get", method: "GET", path: "/parameters/{name}", permission: "user", handlerKey: "api" },
53
+ { id: "update", method: "PUT", path: "/parameters/{name}", permission: "admin", handlerKey: "api" },
54
+ ],
55
+ },
56
+ });
57
+ }
58
+
59
+ function apiFunction(scope: Construct, accountNumber: string, prefixes: string[]): lambda.IFunction {
60
+ // The bundle is built when this package is built (scripts/build-lambda.mjs), so a deployment
61
+ // that installs the package needs no bundler of its own.
62
+ const fn = new lambda.Function(scope, "ParametersApi", {
63
+ code: lambda.Code.fromAsset(path.join(__dirname, "..", "lambda-dist")),
64
+ description: "Example plugin API: reads the allowed SSM parameters in this account.",
65
+ handler: "index.handler",
66
+ runtime: lambda.Runtime.NODEJS_22_X,
67
+ timeout: Duration.seconds(10),
68
+ environment: {
69
+ PLUGIN_ACCOUNT_NUMBER: accountNumber,
70
+ PARAMETER_PREFIXES: prefixes.join(","),
71
+ },
72
+ });
73
+
74
+ // The policy mirrors the handler's own rule, so neither alone is the only thing standing between a
75
+ // caller and the rest of the account's parameters.
76
+ const arns = prefixes.map((prefix) => `arn:aws:ssm:*:${accountNumber}:parameter${prefix}*`);
77
+ fn.addToRolePolicy(
78
+ new iam.PolicyStatement({
79
+ actions: ["ssm:GetParameter", "ssm:GetParameters", "ssm:PutParameter"],
80
+ resources: arns,
81
+ })
82
+ );
83
+ // GetParametersByPath authorizes on the path itself, not the parameters under it.
84
+ fn.addToRolePolicy(
85
+ new iam.PolicyStatement({
86
+ actions: ["ssm:GetParametersByPath"],
87
+ resources: prefixes.map((prefix) => `arn:aws:ssm:*:${accountNumber}:parameter${prefix.replace(/\/$/, "")}`),
88
+ })
89
+ );
90
+ return fn;
91
+ }
92
+
93
+ // From scope.ts, never from parameters.ts: a re-export here decides what a consumer has to have
94
+ // installed to synthesize, and parameters.ts imports the AWS SDK client the Lambda uses.
95
+ export { assertInScope, configuredPrefixes, ParameterScopeError } from "./scope";
96
+ export type { ParameterDetail, ParameterSummary, ParameterStore } from "./scope";
@@ -0,0 +1,54 @@
1
+ import { SSMClient, GetParameterCommand, GetParametersByPathCommand, PutParameterCommand } from "@aws-sdk/client-ssm";
2
+ import type { ParameterStore, ParameterSummary } from "./scope";
3
+
4
+ // The SSM-backed half of the plugin's rules, loaded only inside the Lambda. Everything the
5
+ // registration needs lives in scope.ts, which imports nothing from the AWS SDK.
6
+
7
+ const isSecret = (type?: string) => type === "SecureString";
8
+
9
+ export function ssmParameterStore(client: SSMClient): ParameterStore {
10
+ return {
11
+ async list(prefixes) {
12
+ const out: ParameterSummary[] = [];
13
+ for (const prefix of prefixes) {
14
+ let NextToken: string | undefined;
15
+ do {
16
+ const page = await client.send(
17
+ new GetParametersByPathCommand({ Path: prefix.replace(/\/$/, ""), Recursive: true, WithDecryption: false, NextToken })
18
+ );
19
+ for (const p of page.Parameters ?? []) {
20
+ if (!p.Name) continue;
21
+ out.push({
22
+ name: p.Name,
23
+ type: p.Type ?? "String",
24
+ ...(p.LastModifiedDate ? { lastModified: p.LastModifiedDate.toISOString() } : {}),
25
+ ...(p.Version === undefined ? {} : { version: p.Version }),
26
+ });
27
+ }
28
+ NextToken = page.NextToken;
29
+ } while (NextToken);
30
+ }
31
+ return out.sort((a, b) => a.name.localeCompare(b.name));
32
+ },
33
+
34
+ async get(name) {
35
+ // WithDecryption stays false on purpose: a SecureString comes back as metadata only.
36
+ const res = await client.send(new GetParameterCommand({ Name: name, WithDecryption: false }));
37
+ const p = res.Parameter ?? {};
38
+ const secret = isSecret(p.Type);
39
+ return {
40
+ name,
41
+ type: p.Type ?? "String",
42
+ secret,
43
+ ...(secret ? {} : { value: p.Value ?? "" }),
44
+ ...(p.LastModifiedDate ? { lastModified: p.LastModifiedDate.toISOString() } : {}),
45
+ ...(p.Version === undefined ? {} : { version: p.Version }),
46
+ };
47
+ },
48
+
49
+ async put(name, value) {
50
+ const res = await client.send(new PutParameterCommand({ Name: name, Value: value, Overwrite: true }));
51
+ return res.Version ?? 0;
52
+ },
53
+ };
54
+ }
package/src/scope.ts ADDED
@@ -0,0 +1,46 @@
1
+ // What this plugin is willing to touch, and nothing that talks to AWS. The registration
2
+ // (index.ts) re-exports from here, so loading it on a developer's machine never pulls the
3
+ // Lambda's runtime dependencies in with it — a consumer installs the package without them.
4
+
5
+ export interface ParameterSummary {
6
+ readonly name: string;
7
+ /** SecureString values are never fetched decrypted, so the type is how the UI knows to say so. */
8
+ readonly type: string;
9
+ readonly lastModified?: string;
10
+ readonly version?: number;
11
+ }
12
+
13
+ export interface ParameterDetail extends ParameterSummary {
14
+ /** Absent for a SecureString: this sample never displays a decrypted secret. */
15
+ readonly value?: string;
16
+ readonly secret: boolean;
17
+ }
18
+
19
+ export class ParameterScopeError extends Error {}
20
+
21
+ /** Prefixes this deployment allows, from the registration. Empty = the plugin can touch nothing. */
22
+ export function configuredPrefixes(raw = process.env.PARAMETER_PREFIXES): string[] {
23
+ return (raw ?? "")
24
+ .split(",")
25
+ .map((p) => p.trim())
26
+ .filter(Boolean)
27
+ .map((p) => (p.endsWith("/") ? p : `${p}/`));
28
+ }
29
+
30
+ /** A name is in scope only if it sits under a configured prefix and cannot climb out of it. A `..`
31
+ * segment, a missing leading slash, or a name that merely starts with the prefix as a string
32
+ * (`/app-secrets` against `/app`) are all refused. */
33
+ export function assertInScope(name: string, prefixes: string[]): void {
34
+ if (!name.startsWith("/")) throw new ParameterScopeError("A parameter name starts with /");
35
+ if (name.split("/").includes("..")) throw new ParameterScopeError("A parameter name cannot contain ..");
36
+ if (!/^[a-zA-Z0-9_.\-/]+$/.test(name)) throw new ParameterScopeError("That parameter name has characters SSM does not allow");
37
+ if (!prefixes.some((prefix) => name.startsWith(prefix))) {
38
+ throw new ParameterScopeError(`This plugin is limited to ${prefixes.join(", ") || "no prefixes"}`);
39
+ }
40
+ }
41
+
42
+ export interface ParameterStore {
43
+ list(prefixes: string[]): Promise<ParameterSummary[]>;
44
+ get(name: string): Promise<ParameterDetail>;
45
+ put(name: string, value: string): Promise<number>;
46
+ }
package/web/app.css ADDED
@@ -0,0 +1,16 @@
1
+ /* The component styles come from @fntio/infra-client-plugin-ui and the values from the portal's theme;
2
+ this file is only what is specific to this plugin's own page. */
3
+ :root { color-scheme: light dark; }
4
+
5
+ body {
6
+ margin: 0;
7
+ /* The frame is the whole page, so the page gutter is the plugin's: the portal's own is 2rem. */
8
+ padding: 2rem;
9
+ /* Transparent: the portal paints the surface behind the frame. */
10
+ background: transparent;
11
+ }
12
+
13
+ /* The value column carries the widest, least predictable content. */
14
+ .value { max-width: 22rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
15
+ .editing { display: flex; align-items: center; gap: 0.4rem; }
16
+ .editing .ic-input { min-width: 12rem; }
package/web/app.ts ADDED
@@ -0,0 +1,296 @@
1
+ import { connectToInfraClient, PluginAccountContext, PluginApiError, PluginContext } from "@fntio/infra-client-plugin-sdk/browser";
2
+ import { applyPluginUi } from "@fntio/infra-client-plugin-ui";
3
+
4
+ // A plugin frontend in plain TypeScript — no framework, to show the SDK does not ask for one. The portal
5
+ // has already signed the user in; this page never sees a password, a refresh token, or a login screen.
6
+ // Its look comes from the shared UI classes, so it reads as part of the app without copying anything.
7
+
8
+ interface ParameterSummary {
9
+ name: string;
10
+ type: string;
11
+ lastModified?: string;
12
+ version?: number;
13
+ }
14
+
15
+ const app = () => document.getElementById("app") as HTMLElement;
16
+
17
+ function el<K extends keyof HTMLElementTagNameMap>(
18
+ tag: K,
19
+ className?: string,
20
+ text?: string
21
+ ): HTMLElementTagNameMap[K] {
22
+ const node = document.createElement(tag);
23
+ if (className) node.className = className;
24
+ if (text !== undefined) node.textContent = text;
25
+ return node;
26
+ }
27
+
28
+ const button = (label: string, variant = "outline"): HTMLButtonElement =>
29
+ el("button", `ic-btn ic-btn-sm ic-btn-${variant}`, label);
30
+
31
+ function show(...nodes: (Node | null)[]): void {
32
+ app().replaceChildren(...nodes.filter((n): n is Node => n !== null));
33
+ }
34
+
35
+ /** The three states every plugin page needs, drawn the way the portal draws them. */
36
+ function loading(): HTMLElement {
37
+ const card = el("div", "ic-card");
38
+ for (let i = 0; i < 3; i += 1) card.appendChild(el("div", "ic-skeleton"));
39
+ return card;
40
+ }
41
+
42
+ function empty(message: string, hint?: string): HTMLElement {
43
+ const box = el("div", "ic-empty");
44
+ box.appendChild(el("p", undefined, message));
45
+ if (hint) box.appendChild(el("p", "ic-caption", hint));
46
+ return box;
47
+ }
48
+
49
+ function failure(message: string, retry?: () => void): HTMLElement {
50
+ const box = el("div", "ic-error");
51
+ box.appendChild(el("p", "ic-error-title", "That did not work"));
52
+ box.appendChild(el("p", "ic-caption", message));
53
+ if (retry) {
54
+ const again = button("Try again", "outline");
55
+ again.addEventListener("click", retry);
56
+ box.appendChild(again);
57
+ }
58
+ return box;
59
+ }
60
+
61
+ async function start(): Promise<void> {
62
+ applyPluginUi(document);
63
+ let context: PluginContext;
64
+ try {
65
+ context = await connectToInfraClient();
66
+ } catch (err) {
67
+ // The portal is the only way in; outside its frame there is nothing this page can do.
68
+ show(failure((err as Error).message));
69
+ return;
70
+ }
71
+
72
+ context.on("accessChanged", () => void draw(context));
73
+ context.on("sessionEnded", () => show(empty("Your session ended.", "Sign in again in the portal.")));
74
+ await draw(context);
75
+ }
76
+
77
+ // Which account the page works in is this plugin's own state: the portal only says which accounts are open.
78
+ let chosen: string | null = null;
79
+ /** An account the user was working in and no longer holds. Said out loud, never swapped for another:
80
+ * quietly moving someone to a different account is how an edit lands in the wrong one. */
81
+ let lost: string | null = null;
82
+
83
+ /** The chosen account while it is still permitted. Only the first visit defaults to the first account. */
84
+ function currentAccount(context: PluginContext): PluginAccountContext | null {
85
+ const accounts = context.accounts();
86
+ if (chosen && !accounts.some((a) => a.accountNumber === chosen)) {
87
+ lost = chosen;
88
+ chosen = null;
89
+ }
90
+ // Granted back (or a refresh that briefly dropped it): pick up where the user was.
91
+ if (lost && accounts.some((a) => a.accountNumber === lost)) {
92
+ chosen = lost;
93
+ lost = null;
94
+ }
95
+ if (!chosen && !lost) chosen = accounts[0]?.accountNumber ?? null;
96
+ return accounts.find((a) => a.accountNumber === chosen) ?? null;
97
+ }
98
+
99
+ /** The plugin draws its own header: the portal hands the frame the whole page. Title on the left; the
100
+ * account picker on the right only when there is a choice. */
101
+ function pageHeader(context: PluginContext): HTMLElement {
102
+ const header = el("div", "ic-header");
103
+ const titles = el("div");
104
+ titles.appendChild(el("h1", "ic-title", "Parameters"));
105
+ titles.appendChild(el("p", "ic-caption", `${context.plugin.displayName} · ${context.plugin.version}`));
106
+ header.appendChild(titles);
107
+
108
+ const accounts = context.accounts();
109
+ const selected = currentAccount(context);
110
+ if (accounts.length > 1 || (accounts.length === 1 && !selected)) {
111
+ const select = el("select", "ic-select");
112
+ select.setAttribute("aria-label", "Account");
113
+ if (!selected) {
114
+ const placeholder = el("option", undefined, "Choose an account");
115
+ placeholder.value = "";
116
+ placeholder.selected = true;
117
+ placeholder.disabled = true;
118
+ select.appendChild(placeholder);
119
+ }
120
+ for (const account of accounts) {
121
+ const option = el("option", undefined, `${account.accountNumber} · ${account.displayName}`);
122
+ option.value = account.accountNumber;
123
+ option.selected = account.accountNumber === selected?.accountNumber;
124
+ select.appendChild(option);
125
+ }
126
+ select.addEventListener("change", () => {
127
+ chosen = select.value;
128
+ lost = null;
129
+ void draw(context);
130
+ });
131
+ header.appendChild(select);
132
+ } else if (selected) {
133
+ header.appendChild(el("span", "ic-caption ic-mono", `${selected.accountNumber} · ${selected.displayName}`));
134
+ }
135
+ return header;
136
+ }
137
+
138
+ /** Only the newest draw may paint: a slow load for one account must not land under another's header. */
139
+ let drawing = 0;
140
+
141
+ async function draw(context: PluginContext): Promise<void> {
142
+ const run = ++drawing;
143
+ const page = (...nodes: (Node | null)[]) => show(pageHeader(context), ...nodes);
144
+ const account = currentAccount(context);
145
+ if (!account) {
146
+ page(
147
+ lost
148
+ ? empty(`Your access to account ${lost} ended.`, context.accounts().length ? "Choose another account to continue." : undefined)
149
+ : empty("No account available.", "You have no account with access to this plugin.")
150
+ );
151
+ return;
152
+ }
153
+ // `entry` is which page the portal opened, and it decides what this frame offers. The API enforces the
154
+ // same thing independently — this only keeps the UI honest.
155
+ const canEdit = context.entry === "admin" && context.hasPermission("admin", account.accountNumber);
156
+ page(loading());
157
+
158
+ let parameters: ParameterSummary[];
159
+ let prefixes: string[];
160
+ try {
161
+ const res = await context.api.get<{ parameters: ParameterSummary[]; prefixes: string[] }>(
162
+ `accounts/${account.accountNumber}/parameters`
163
+ );
164
+ parameters = res.parameters;
165
+ prefixes = res.prefixes;
166
+ } catch (err) {
167
+ if (run === drawing) page(failure(explain(err), () => void draw(context)));
168
+ return;
169
+ }
170
+ if (run !== drawing) return;
171
+
172
+ if (!parameters.length) {
173
+ page(empty("No parameters yet.", `Nothing exists under ${prefixes.join(", ")}.`));
174
+ return;
175
+ }
176
+
177
+ const card = el("div", "ic-card");
178
+ const head = el("div", "ic-card-head");
179
+ head.appendChild(el("h2", "ic-card-title", `${parameters.length} parameter${parameters.length === 1 ? "" : "s"}`));
180
+ head.appendChild(el("span", "ic-caption", prefixes.join(", ")));
181
+ card.appendChild(head);
182
+
183
+ const wrap = el("div", "ic-table-wrap");
184
+ const table = el("table", "ic-table");
185
+ const thead = el("thead");
186
+ const headRow = el("tr");
187
+ for (const [label, width] of [["Name", ""], ["Value", ""], ["Type", ""], ["", ""]] as const) {
188
+ const th = el("th", undefined, label);
189
+ if (width) th.style.width = width;
190
+ headRow.appendChild(th);
191
+ }
192
+ thead.appendChild(headRow);
193
+ table.appendChild(thead);
194
+ const body = el("tbody");
195
+ for (const parameter of parameters) body.appendChild(row(context, account.accountNumber, parameter, canEdit));
196
+ table.appendChild(body);
197
+ wrap.appendChild(table);
198
+ card.appendChild(wrap);
199
+ page(card);
200
+ }
201
+
202
+ function row(context: PluginContext, accountNumber: string, parameter: ParameterSummary, canEdit: boolean): HTMLElement {
203
+ const secret = parameter.type === "SecureString";
204
+ const tr = el("tr");
205
+ tr.appendChild(el("td", "ic-mono", parameter.name));
206
+
207
+ const valueCell = el("td", "value ic-mono ic-muted", "—");
208
+ tr.appendChild(valueCell);
209
+
210
+ const typeCell = el("td");
211
+ typeCell.appendChild(el("span", `ic-badge${secret ? " ic-badge-amber" : ""}`, secret ? "secret" : parameter.type));
212
+ tr.appendChild(typeCell);
213
+
214
+ const actions = el("td");
215
+ const reveal = button(secret ? "Details" : "Show");
216
+ reveal.addEventListener("click", async () => {
217
+ reveal.disabled = true;
218
+ try {
219
+ const res = await context.api.get<{ parameter: { value?: string; secret: boolean } }>(
220
+ `accounts/${accountNumber}/parameters/${encodeURIComponent(parameter.name)}`
221
+ );
222
+ if (res.parameter.secret) {
223
+ // An encrypted value is never fetched decrypted; say so rather than showing an empty cell.
224
+ valueCell.textContent = "hidden — encrypted";
225
+ reveal.remove();
226
+ return;
227
+ }
228
+ valueCell.textContent = res.parameter.value ?? "";
229
+ valueCell.className = "value ic-mono";
230
+ reveal.remove();
231
+ if (canEdit) actions.appendChild(editButton(context, accountNumber, parameter.name, valueCell, actions));
232
+ } catch (err) {
233
+ valueCell.replaceChildren(el("span", "ic-caption", explain(err)));
234
+ reveal.disabled = false;
235
+ }
236
+ });
237
+ actions.appendChild(reveal);
238
+ tr.appendChild(actions);
239
+ return tr;
240
+ }
241
+
242
+ function editButton(
243
+ context: PluginContext,
244
+ accountNumber: string,
245
+ name: string,
246
+ valueCell: HTMLElement,
247
+ actions: HTMLElement
248
+ ): HTMLButtonElement {
249
+ const edit = button("Edit");
250
+ edit.addEventListener("click", () => {
251
+ const original = valueCell.textContent ?? "";
252
+ const input = el("input", "ic-input");
253
+ input.value = original;
254
+ input.setAttribute("aria-label", `New value for ${name}`);
255
+ const save = button("Save", "primary");
256
+ const cancel = button("Cancel", "ghost");
257
+ const editing = el("div", "editing");
258
+ editing.append(input, save, cancel);
259
+ valueCell.replaceChildren(editing);
260
+ edit.remove();
261
+ input.focus();
262
+
263
+ const done = (text: string) => {
264
+ valueCell.replaceChildren();
265
+ valueCell.textContent = text;
266
+ actions.appendChild(editButton(context, accountNumber, name, valueCell, actions));
267
+ };
268
+ cancel.addEventListener("click", () => done(original));
269
+ save.addEventListener("click", async () => {
270
+ // An explicit confirmation before a write: this edits live configuration.
271
+ if (!window.confirm(`Set ${name} to "${input.value}"?`)) return;
272
+ save.disabled = true;
273
+ try {
274
+ await context.api.put(`accounts/${accountNumber}/parameters/${encodeURIComponent(name)}`, { value: input.value });
275
+ done(input.value);
276
+ } catch (err) {
277
+ save.disabled = false;
278
+ const message = el("span", "ic-caption", explain(err));
279
+ editing.appendChild(message);
280
+ }
281
+ });
282
+ });
283
+ return edit;
284
+ }
285
+
286
+ /** Turn an SDK error into something a person can act on. */
287
+ function explain(err: unknown): string {
288
+ if (err instanceof PluginApiError) {
289
+ const body = err.body as { message?: string } | undefined;
290
+ if (err.status === 403) return "You no longer have access to this plugin in this account.";
291
+ return body?.message ?? `The request failed (${err.status}).`;
292
+ }
293
+ return (err as Error).message || "Something went wrong.";
294
+ }
295
+
296
+ void start();
package/web/index.html ADDED
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Application parameters</title>
7
+ <link rel="stylesheet" href="app.css" />
8
+ </head>
9
+ <body>
10
+ <!-- The portal paints this document's theme tokens; nothing here hardcodes a colour or a product name. -->
11
+ <main id="app" class="ic-page" aria-live="polite">
12
+ <p class="muted">Connecting to the portal…</p>
13
+ </main>
14
+ <script src="app.js"></script>
15
+ </body>
16
+ </html>
@@ -0,0 +1,16 @@
1
+ /* The component styles come from @fntio/infra-client-plugin-ui and the values from the portal's theme;
2
+ this file is only what is specific to this plugin's own page. */
3
+ :root { color-scheme: light dark; }
4
+
5
+ body {
6
+ margin: 0;
7
+ /* The frame is the whole page, so the page gutter is the plugin's: the portal's own is 2rem. */
8
+ padding: 2rem;
9
+ /* Transparent: the portal paints the surface behind the frame. */
10
+ background: transparent;
11
+ }
12
+
13
+ /* The value column carries the widest, least predictable content. */
14
+ .value { max-width: 22rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
15
+ .editing { display: flex; align-items: center; gap: 0.4rem; }
16
+ .editing .ic-input { min-width: 12rem; }