@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/LICENSE ADDED
@@ -0,0 +1,93 @@
1
+ Elastic License 2.0
2
+
3
+ URL: https://www.elastic.co/licensing/elastic-license
4
+
5
+ ## Acceptance
6
+
7
+ By using the software, you agree to all of the terms and conditions below.
8
+
9
+ ## Copyright License
10
+
11
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
12
+ non-sublicensable, non-transferable license to use, copy, distribute, make
13
+ available, and prepare derivative works of the software, in each case subject to
14
+ the limitations and conditions below.
15
+
16
+ ## Limitations
17
+
18
+ You may not provide the software to third parties as a hosted or managed
19
+ service, where the service provides users with access to any substantial set of
20
+ the features or functionality of the software.
21
+
22
+ You may not move, change, disable, or circumvent the license key functionality
23
+ in the software, and you may not remove or obscure any functionality in the
24
+ software that is protected by the license key.
25
+
26
+ You may not alter, remove, or obscure any licensing, copyright, or other notices
27
+ of the licensor in the software. Any use of the licensor’s trademarks is subject
28
+ to applicable law.
29
+
30
+ ## Patents
31
+
32
+ The licensor grants you a license, under any patent claims the licensor can
33
+ license, or becomes able to license, to make, have made, use, sell, offer for
34
+ sale, import and have imported the software, in each case subject to the
35
+ limitations and conditions in this license. This license does not cover any
36
+ patent claims that you cause to be infringed by modifications or additions to
37
+ the software. If you or your company make any written claim that the software
38
+ infringes or contributes to infringement of any patent, your patent license for
39
+ the software granted under these terms ends immediately. If your company makes
40
+ such a claim, your patent license ends immediately for work on behalf of your
41
+ company.
42
+
43
+ ## Notices
44
+
45
+ You must ensure that anyone who gets a copy of any part of the software from you
46
+ also gets a copy of these terms.
47
+
48
+ If you modify the software, you must include in any modified copies of the
49
+ software prominent notices stating that you have modified the software.
50
+
51
+ ## No Other Rights
52
+
53
+ These terms do not imply any licenses other than those expressly granted in
54
+ these terms.
55
+
56
+ ## Termination
57
+
58
+ If you use the software in violation of these terms, such use is not licensed,
59
+ and your licenses will automatically terminate. If the licensor provides you
60
+ with a notice of your violation, and you cease all violation of this license no
61
+ later than 30 days after you receive that notice, your licenses will be
62
+ reinstated retroactively. However, if you violate these terms after such
63
+ reinstatement, any additional violation of these terms will cause your licenses
64
+ to terminate automatically and permanently.
65
+
66
+ ## No Liability
67
+
68
+ *As far as the law allows, the software comes as is, without any warranty or
69
+ condition, and the licensor will not be liable to you for any damages arising
70
+ out of these terms or the use or nature of the software, under any kind of
71
+ legal claim.*
72
+
73
+ ## Definitions
74
+
75
+ The **licensor** is the entity offering these terms, and the **software** is the
76
+ software the licensor makes available under these terms, including any portion
77
+ of it.
78
+
79
+ **you** refers to the individual or entity agreeing to these terms.
80
+
81
+ **your company** is any legal entity, sole proprietorship, or other kind of
82
+ organization that you work for, plus all organizations that have control over,
83
+ are under the control of, or are under common control with that
84
+ organization. **control** means ownership of substantially all the assets of an
85
+ entity, or the power to direct its management and policies by vote, contract, or
86
+ otherwise. Control can be direct or indirect.
87
+
88
+ **your licenses** are all the licenses granted to you for the software under
89
+ these terms.
90
+
91
+ **use** means anything you do with the software requiring one of your licenses.
92
+
93
+ **trademark** means trademarks, service marks, and similar rights.
package/README.md CHANGED
@@ -1,3 +1,348 @@
1
- # Temporary Holding Version
1
+ # @fntio/infra-client-plugin-example
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The reference plugin, a scoped **AWS Systems Manager Parameter Store** viewer and editor, and the
4
+ step-by-step guide to getting a plugin of your own running. The example is deliberately small enough
5
+ to read in one sitting; the guide below is the order of operations, with the code each step needs.
6
+
7
+ A plugin is a small app of your own inside your infra-client portal. The portal does sign-in,
8
+ accounts, permissions and theme. You write three things: a CDK registration, a Lambda, and a web
9
+ page. Then you deploy your CDK app as usual. Needs `@fntio/infra-client` 0.2.0 or later: the plugin
10
+ packages at 0.2.x and the portal must be on the same message protocol (see the SDK README,
11
+ "Versioning"), and a page built with the 0.1.x packages has to be rebuilt.
12
+
13
+ ## How it fits together
14
+
15
+ | Who | What |
16
+ | --- | --- |
17
+ | Portal | Shows your page in an iframe on its own origin, filling the whole page beside the sidebar, and hands it a token, the AWS accounts the caller may use (each with its permissions) and the theme. The portal draws no header above it and tracks no selected account: your page draws its own title, and keeps its own choice of account, with a picker if it needs one, from `context.accounts()`. Your page runs as the signed-in user (see "Things that bite"). |
18
+ | **Your page** | Calls your own API at `/api/plugins/<id>/accounts/<account>/…` with that token. |
19
+ | Portal | Checks the token and the grant before your Lambda runs. No grant means your code is never called. |
20
+ | **Your Lambda** | Gets who is calling and for which account, does the work, answers JSON. |
21
+ | Admins | Grant `user` or `admin` on your plugin per account, on the same Users and Groups screens as roles. |
22
+
23
+ ## The steps
24
+
25
+ In this order. Each one is checked on its own before the next.
26
+
27
+ ### 1. Turn the feature on, with no plugin yet
28
+
29
+ In your deployment's config, then deploy the hub stacks as you normally do.
30
+
31
+ ```ts
32
+ // config/project.ts
33
+ plugins: { enabled: true },
34
+ ```
35
+
36
+ **Check:** the portal still works, and a signed-in call to `GET /api/plugins` returns
37
+ `{"schemaVersion":1,"refreshAfterSeconds":60,"plugins":[]}`.
38
+
39
+ ### 2. Get the plugin packages
40
+
41
+ They are not on npm yet. Every [GitHub Release](https://github.com/FNTIO/aws-infrastructure-client/releases)
42
+ from v0.1.18 on carries them beside the core tarball: download the four plugin `.tgz` files from the
43
+ Release your portal runs and install them next to your `@fntio/infra-client` one. The four plugin
44
+ packages share one version (0.2.0 today) and release together.
45
+
46
+ ```bash
47
+ cd <your cdk project> && mkdir -p vendor
48
+ gh release download v<version your portal runs> --repo FNTIO/aws-infrastructure-client \
49
+ --pattern 'fntio-infra-client-plugin-*.tgz' --dir vendor
50
+ ```
51
+
52
+ Older Release, or no download access? Build the same files from the source at that tag:
53
+ `git clone https://github.com/FNTIO/aws-infrastructure-client && git checkout v<version> && npm ci && npm run pack:plugins`
54
+ writes them into `packs/`.
55
+
56
+ ```jsonc
57
+ // package.json of your CDK project
58
+ "dependencies": {
59
+ "@fntio/infra-client": "file:vendor/fntio-infra-client-<version>.tgz",
60
+ "@fntio/infra-client-plugin-sdk": "file:vendor/fntio-infra-client-plugin-sdk-0.2.0.tgz",
61
+ "@fntio/infra-client-plugin-cdk": "file:vendor/fntio-infra-client-plugin-cdk-0.2.0.tgz",
62
+ "@fntio/infra-client-plugin-ui": "file:vendor/fntio-infra-client-plugin-ui-0.2.0.tgz"
63
+ }
64
+ ```
65
+
66
+ **Optional, recommended:** also install `fntio-infra-client-plugin-example-0.2.0.tgz` and register
67
+ this example first. It is a finished, working plugin, so it proves your deploy, the grants and the
68
+ iframe before you write a line. Its tarball carries the built frontend and a prebundled Lambda, so
69
+ your synth runs no bundler and needs no Docker.
70
+
71
+ ```ts
72
+ import { addAccessPortal } from "@fntio/infra-client";
73
+ import { parametersPlugin } from "@fntio/infra-client-plugin-example";
74
+
75
+ addAccessPortal(app, {
76
+ project: PROJECT,
77
+ resourcePrefix: RESOURCE_PREFIX,
78
+ plugins: [
79
+ parametersPlugin({
80
+ accountNumber: "111111111111",
81
+ region: "eu-central-1",
82
+ prefixes: ["/myapp/prod"],
83
+ }),
84
+ ],
85
+ });
86
+ ```
87
+
88
+ ### 3. Write the registration
89
+
90
+ One file in your CDK project. It declares what the plugin is: its id, the accounts it serves, the
91
+ two permission levels, the sidebar entries, and the routes with the Lambda that answers them. Copy
92
+ this package's `src/index.ts` and change the manifest; the pieces you will replace are the routes,
93
+ the handler and the frontend, and the shape around them is the same for every plugin.
94
+
95
+ ```ts
96
+ // lib/my-plugin.ts
97
+ import * as path from "node:path";
98
+ import * as lambda from "aws-cdk-lib/aws-lambda";
99
+ import { definePlugin } from "@fntio/infra-client-plugin-cdk";
100
+
101
+ export const myPlugin = definePlugin({
102
+ schemaVersion: 1,
103
+ hostApiVersion: 1,
104
+ id: "parameters", // lowercase, digits, hyphens. Used in every URL and grant.
105
+ displayName: "Application parameters",
106
+ version: "1.0.0",
107
+ enabled: true,
108
+ accounts: [{ accountNumber: "111111111111" }],
109
+ permissions: [
110
+ { id: "user", label: "View parameters" },
111
+ { id: "admin", label: "Edit parameters" }, // admin includes user when both are declared
112
+ ],
113
+ ui: {
114
+ assets: path.join(__dirname, "../web-dist"), // your built web page; must exist at synth
115
+ user: { label: "Parameters", entry: "index.html#/user" },
116
+ admin: { label: "Parameters", entry: "index.html#/admin" },
117
+ },
118
+ backend: {
119
+ handlers: {
120
+ api: (scope) => new lambda.Function(scope, "MyPluginApi", {
121
+ code: lambda.Code.fromAsset(path.join(__dirname, "../lambda-dist")),
122
+ handler: "index.handler",
123
+ runtime: lambda.Runtime.NODEJS_22_X,
124
+ }),
125
+ },
126
+ routes: [ // paths are relative to the account; {name} is a single segment
127
+ { id: "list", method: "GET", path: "/parameters", permission: "user", handlerKey: "api" },
128
+ { id: "update", method: "PUT", path: "/parameters/{name}", permission: "admin", handlerKey: "api" },
129
+ ],
130
+ },
131
+ });
132
+ ```
133
+
134
+ Give that Lambda an IAM policy for exactly what it may touch. The portal decides who may call a
135
+ route; your policy decides what the code may do.
136
+
137
+ ### 4. Write the Lambda
138
+
139
+ By the time it runs, the portal has already checked the caller. Read who and which account from
140
+ the context the portal attached, never from the request itself.
141
+
142
+ ```ts
143
+ // src/handler.ts, bundled to lambda-dist/index.js
144
+ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
145
+ import { createPluginLogger, pluginAuthorizerContextOf, pluginRoutePath }
146
+ from "@fntio/infra-client-plugin-sdk/backend";
147
+
148
+ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> {
149
+ const ctx = pluginAuthorizerContextOf(event); // who, which account, which route. Throws if missing.
150
+ const log = createPluginLogger(ctx);
151
+ const route = pluginRoutePath(event.path, ctx); // "/parameters" or "/parameters/db-url"
152
+
153
+ if (event.httpMethod === "GET" && route === "/parameters") {
154
+ const items = await listParameters(ctx.accountNumber); // your code
155
+ log.info("listed", { count: items.length });
156
+ return json(200, { items });
157
+ }
158
+ if (event.httpMethod === "PUT" && event.pathParameters?.name) {
159
+ // ... validate, write, return 200. Business failures are 400/404/409, never 401.
160
+ }
161
+ return json(404, { message: "Unknown route" });
162
+ }
163
+
164
+ const json = (statusCode: number, body: unknown): APIGatewayProxyResult => ({
165
+ statusCode, headers: { "content-type": "application/json" }, body: JSON.stringify(body),
166
+ });
167
+ ```
168
+
169
+ Bundle it the way your project already bundles Lambdas (esbuild, `NodejsFunction`, whatever you
170
+ use). The Lambda runs in the hub account under the role you give it.
171
+
172
+ ### 5. Write the web page
173
+
174
+ A normal web bundle, any framework or none. It never logs in and never stores a token. It asks the
175
+ portal to connect, then calls your API through the SDK.
176
+
177
+ ```ts
178
+ // web/app.ts, bundled to web-dist/ next to index.html
179
+ import { connectToInfraClient } from "@fntio/infra-client-plugin-sdk/browser";
180
+ import { applyPluginUi } from "@fntio/infra-client-plugin-ui";
181
+
182
+ applyPluginUi(document); // the portal's look: ic-btn, ic-table, ic-card classes
183
+ const portal = await connectToInfraClient(); // waits for the portal; also applies its theme
184
+
185
+ let chosen: string | undefined; // which account the page works in is its own state
186
+
187
+ async function render() {
188
+ const accounts = portal.accounts(); // every account the caller may use here
189
+ const account = accounts.find((a) => a.accountNumber === chosen) ?? accounts[0];
190
+ if (!account) return;
191
+ const { items } = await portal.api.get<{ items: unknown[] }>(`accounts/${account.accountNumber}/parameters`);
192
+ document.getElementById("app")!.textContent = `${items.length} parameters`;
193
+ }
194
+ portal.on("accessChanged", render); // their grant changed; the chosen account may be gone
195
+ portal.on("sessionEnded", () => { document.getElementById("app")!.textContent = "Signed out."; });
196
+ await render();
197
+ ```
198
+
199
+ `portal.api.get/post/put/delete` adds the token for you, only for paths under your plugin's API
200
+ root. If your page is Angular, `createPluginInterceptor(portal)` from
201
+ `@fntio/infra-client-plugin-ui/angular` does the same for `HttpClient`.
202
+
203
+ ### 6. Build, register, look at the diff
204
+
205
+ Build the web page and the Lambda first; the registration checks that `web-dist/index.html` exists
206
+ and fails synth if it does not. Then add the plugin where your portal is already composed.
207
+
208
+ ```ts
209
+ // bin/app.ts
210
+ import { myPlugin } from "../lib/my-plugin";
211
+
212
+ addAccessPortal(app, { project: PROJECT, resourcePrefix: RESOURCE_PREFIX, plugins: [myPlugin] });
213
+ ```
214
+
215
+ ```bash
216
+ npm run build:web && npm run build:lambda # your own scripts
217
+ npx cdk diff
218
+ ```
219
+
220
+ **Expect in the diff:** your Lambda, its gateway methods, an authorizer (only for the first plugin),
221
+ one asset upload and one catalog record. Nothing else should change. Then `npx cdk deploy` the hub
222
+ stacks.
223
+
224
+ ### 7. Grant yourself access and try it
225
+
226
+ Nobody has plugin access by default, not even admins. In the portal: **Users → your user → Plugins →
227
+ Grant plugin access**, pick the account and `admin`.
228
+
229
+ **Check three things:** the entry appears in the sidebar (within a minute, or reload) and the page
230
+ loads with your data. Calling a route with no token gives 401. A user without a grant gets 403 and
231
+ never reaches your Lambda. Every decision is a line in the authorizer's log group, and refused calls
232
+ show on the Activity page.
233
+
234
+ ## Working on it locally
235
+
236
+ You do not need to deploy for every change to the page. The SDK ships a fake portal that speaks the
237
+ real protocol: serve your `web-dist` from any dev server, proxy `/api` to your deployed portal, and
238
+ drive the frame from a small harness page.
239
+
240
+ ```ts
241
+ import { createDevHost } from "@fntio/infra-client-plugin-sdk/fixtures";
242
+
243
+ const frame = document.querySelector("iframe")!;
244
+ const host = createDevHost({ frame, listenOn: window, origin: location.origin, entry: "admin" });
245
+ frame.src = "/plugin/index.html#/admin"; // create the host before setting src
246
+ // then: host.revoke(n), host.grant(a), host.expireSession(), host.setAppearance("dark"), host.signOut()
247
+ ```
248
+
249
+ Its tokens are fakes no backend accepts, so calls that reach a real API still need a real sign-in
250
+ through the portal. Lambda changes need a deploy.
251
+
252
+ ## Changing it later
253
+
254
+ - **Update:** change the code or the registration and redeploy. Open pages get a "new version,
255
+ reload" prompt.
256
+ - **Disable:** `enabled: false`. Hidden and refused, grants kept.
257
+ - **Remove:** delete it from `plugins: [...]` and redeploy. Grants are kept on purpose, so adding the
258
+ same id back restores them.
259
+ - **Turn the whole feature off:** remove every plugin in one deploy, then set `enabled: false` in a
260
+ second one.
261
+
262
+ ## Things that bite
263
+
264
+ - **Your page runs as the signed-in user.** It is a same-origin iframe: nothing stops it from reading the
265
+ portal's tokens or calling core APIs as that user, and a compromised dependency in your bundle would
266
+ have the same power. The SDK's API-root pinning guards against mistakes, not against malice. Treat your
267
+ plugin's code and dependencies with the care of the portal itself; the portal's CSP on
268
+ `/plugin-assets/*` only lets your page load from and talk to the portal.
269
+
270
+ - **Build before synth, every time.** A missing `web-dist/index.html` fails `cdk synth`. Put the
271
+ build in your pipeline before the CDK step.
272
+ - **Ship a new route and the page that uses it together.** For a moment during a deploy the route
273
+ exists but the catalog does not know it yet, and calls get 403.
274
+ - **Never answer 401 from your Lambda.** The page's SDK treats 401 as "token expired" and retries
275
+ once. Use 400, 404 or 409 for your own errors.
276
+ - **One request is for one account.** Use `ctx.accountNumber`. If your Lambda serves only one
277
+ account, refuse any other with 409 instead of serving its own data.
278
+ - **Keep secrets out of logs.** The logger redacts obvious keys, but a value you log yourself is
279
+ logged.
280
+ - **Keep construct ids stable.** Renaming `"MyPluginApi"` replaces the Lambda.
281
+
282
+ ## Read more
283
+
284
+ | Read | When |
285
+ | --- | --- |
286
+ | [plugin-sdk README](../infra-client-plugin-sdk/README.md) | Every SDK function for the page and the Lambda, the manifest rules, the dev host. |
287
+ | [plugin-cdk README](../infra-client-plugin-cdk/README.md) | `definePlugin` in full and the install, update, rollback, disable, remove lifecycle. |
288
+ | [plugin-ui README](../infra-client-plugin-ui/README.md) | The `ic-*` CSS classes and the Angular interceptor. |
289
+ | [Core README, Plugins](../infra-client/README.md#plugins) | Exactly what a deploy creates, how the authorizer decides, what a grant means. |
290
+ | [Wiki: Runbooks, Plugins](../../wiki/Runbooks-Plugins.md) | The admin's side: granting, disabling, removing. |
291
+
292
+ Using a coding agent? Two skills ship inside the packages: `build-infra-client-plugin` in the SDK
293
+ and `integrate-infra-client-plugin` in the CDK package. Point the agent at them and it works in your
294
+ repo's own conventions.
295
+
296
+ ## About this example
297
+
298
+ What it demonstrates is the whole path: a manifest, two permission levels, routes behind the
299
+ portal's shared authorizer, a frontend in an iframe using the browser SDK, and a Lambda using the
300
+ backend SDK.
301
+
302
+ ```
303
+ src/index.ts the registration a customer copies — manifest, handler factory, IAM
304
+ src/handler.ts the Lambda: authorizer context in, business rules out
305
+ src/scope.ts what this plugin will and will not touch — no AWS imports, so the registration loads without the Lambda's dependencies
306
+ src/parameters.ts the SSM-backed store, loaded only inside the Lambda
307
+ web/ the frontend, plain TypeScript, no framework
308
+ ```
309
+
310
+ The plugin's frontend must be built before synth, because `definePlugin` checks that the entry file
311
+ exists: `npm run build -w @fntio/infra-client-plugin-example`.
312
+
313
+ ### What it does, and what it refuses
314
+
315
+ The user permission lists and reads parameters under the configured prefixes. The admin permission
316
+ also writes them, behind an explicit confirmation. Nothing else is reachable:
317
+
318
+ - **Prefixes bound it twice.** The handler refuses any name outside them, and the Lambda's IAM policy
319
+ is scoped to the same prefixes. Neither is the only thing standing in the way.
320
+ - **A prefix is a path, not a string match.** `/app/production` is not reachable through `/app/prod`,
321
+ and a `..` segment is refused outright.
322
+ - **One instance serves one account.** The portal authorizes a request for a particular account; if
323
+ that is not the account this instance was registered for, it answers 409 rather than quietly
324
+ serving its own. Two accounts means two registrations, each with its own function and policy.
325
+ - **Encrypted values stay encrypted.** A `SecureString` is fetched without decryption and returned as
326
+ metadata only, so a viewer sees that a secret exists without seeing it.
327
+ - **Values never reach a log.** The logger records that a parameter changed and its new version.
328
+
329
+ ### Develop
330
+
331
+ ```
332
+ npm install # at the repository root
333
+ npm run build -w @fntio/infra-client-plugin-example
334
+ npx tsc --noEmit && npm test
335
+ ```
336
+
337
+ Live testing uses disposable parameters only. Create a couple under a registered prefix, exercise the
338
+ plugin, then delete them:
339
+
340
+ ```bash
341
+ aws ssm put-parameter --region <region> --overwrite --type String \
342
+ --name <prefix>/db/host --value pg.internal
343
+ aws ssm put-parameter --region <region> --overwrite --type SecureString \
344
+ --name <prefix>/db/password --value not-a-real-secret
345
+ aws ssm delete-parameters --region <region> --names <prefix>/db/host <prefix>/db/password
346
+ ```
347
+
348
+ The `SecureString` is there on purpose: the plugin lists it and never reads its value.
@@ -0,0 +1,10 @@
1
+ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
2
+ import { ParameterStore } from "./scope";
3
+ export interface HandlerDeps {
4
+ readonly store: ParameterStore;
5
+ readonly prefixes: string[];
6
+ readonly homeAccount: string;
7
+ }
8
+ export declare function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>;
9
+ /** Exported for tests: the same path the Lambda takes, with its dependencies handed in. */
10
+ export declare function route(event: APIGatewayProxyEvent, d: HandlerDeps): Promise<APIGatewayProxyResult>;
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handler = handler;
4
+ exports.route = route;
5
+ const client_ssm_1 = require("@aws-sdk/client-ssm");
6
+ const backend_1 = require("@fntio/infra-client-plugin-sdk/backend");
7
+ const parameters_1 = require("./parameters");
8
+ const scope_1 = require("./scope");
9
+ // The plugin's three routes. Authentication and authorization already happened: the shared authorizer
10
+ // verified the caller, matched this request to a registered route, and attached the context below. What
11
+ // is left is the plugin's own business — which is why nothing here re-reads a permission or an account
12
+ // from the request body.
13
+ const ssm = new client_ssm_1.SSMClient({});
14
+ /** The account this function may touch. A route authorized for account A must never act on account B,
15
+ * so a mismatch is refused rather than quietly served from wherever the function happens to run. */
16
+ const HOME_ACCOUNT = process.env.PLUGIN_ACCOUNT_NUMBER ?? "";
17
+ const json = (statusCode, body) => ({
18
+ statusCode,
19
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
20
+ body: JSON.stringify(body),
21
+ });
22
+ function deps() {
23
+ return { store: (0, parameters_1.ssmParameterStore)(ssm), prefixes: (0, scope_1.configuredPrefixes)(), homeAccount: HOME_ACCOUNT };
24
+ }
25
+ async function handler(event) {
26
+ return route(event, deps());
27
+ }
28
+ /** Exported for tests: the same path the Lambda takes, with its dependencies handed in. */
29
+ async function route(event, d) {
30
+ let ctx;
31
+ try {
32
+ ctx = (0, backend_1.pluginAuthorizerContextOf)(event);
33
+ }
34
+ catch {
35
+ // Only reachable if this function is invoked outside its gateway route; fail closed rather than
36
+ // guess who the caller is.
37
+ return json(500, { message: "This request did not come through the portal." });
38
+ }
39
+ const log = (0, backend_1.createPluginLogger)(ctx);
40
+ if (!d.prefixes.length) {
41
+ log.error("no parameter prefixes configured");
42
+ return json(500, { message: "This plugin has no parameter prefixes configured." });
43
+ }
44
+ if (ctx.accountNumber !== d.homeAccount) {
45
+ // The portal authorized an account this deployment of the plugin cannot serve. Reading the home
46
+ // account's parameters anyway would hand the caller another account's data.
47
+ log.error("authorized for an account this plugin does not serve", { requested: ctx.accountNumber });
48
+ return json(409, { message: `This plugin instance only serves account ${d.homeAccount}.` });
49
+ }
50
+ const path = (0, backend_1.pluginRoutePath)(event.path, ctx);
51
+ if (path === null)
52
+ return json(400, { message: "That path is outside this plugin." });
53
+ const method = event.httpMethod.toUpperCase();
54
+ const name = event.pathParameters?.["name"];
55
+ try {
56
+ if (method === "GET" && path === "/parameters") {
57
+ const parameters = await d.store.list(d.prefixes);
58
+ log.info("listed parameters", { count: parameters.length });
59
+ return json(200, { parameters, prefixes: d.prefixes });
60
+ }
61
+ if (method === "GET" && name) {
62
+ const decoded = decodeURIComponent(name);
63
+ (0, scope_1.assertInScope)(decoded, d.prefixes);
64
+ const parameter = await d.store.get(decoded);
65
+ log.info("read parameter", { name: decoded, secret: parameter.secret });
66
+ return json(200, { parameter });
67
+ }
68
+ if (method === "PUT" && name) {
69
+ const decoded = decodeURIComponent(name);
70
+ (0, scope_1.assertInScope)(decoded, d.prefixes);
71
+ const value = JSON.parse(event.body ?? "{}").value;
72
+ if (typeof value !== "string" || !value.length) {
73
+ return json(400, { message: "Send a non-empty string value." });
74
+ }
75
+ const version = await d.store.put(decoded, value);
76
+ // The value itself is never logged — only that it changed, and to which version.
77
+ log.info("updated parameter", { name: decoded, version });
78
+ return json(200, { name: decoded, version });
79
+ }
80
+ }
81
+ catch (err) {
82
+ if (err instanceof scope_1.ParameterScopeError) {
83
+ log.warn("refused an out-of-scope parameter", { error: err.message });
84
+ return json(400, { message: err.message });
85
+ }
86
+ if (err.name === "ParameterNotFound") {
87
+ return json(404, { message: "No such parameter." });
88
+ }
89
+ log.error("parameter operation failed", { error: err.message });
90
+ return json(502, { message: "The parameter store did not answer." });
91
+ }
92
+ return json(404, { message: `No such route: ${method} ${path}` });
93
+ }
@@ -0,0 +1,21 @@
1
+ import { PluginDefinition } from "@fntio/infra-client-plugin-cdk";
2
+ export interface ParametersPluginProps {
3
+ /** The account whose parameters this instance serves. The handler refuses any other, even if the
4
+ * portal authorizes one, so a second account means a second registration with its own function. */
5
+ readonly accountNumber: string;
6
+ /** SSM name prefixes the plugin may read and write. Nothing outside them is reachable — not by a
7
+ * crafted name, not by a `..` segment. Least privilege is enforced twice: here and in the IAM policy. */
8
+ readonly prefixes: readonly string[];
9
+ /** Defaults to the stack's region. */
10
+ readonly region?: string;
11
+ readonly version?: string;
12
+ /** Off by default would hide it from everyone; registrations are usually enabled. */
13
+ readonly enabled?: boolean;
14
+ }
15
+ /** The reference plugin: a scoped Parameter Store viewer and editor.
16
+ *
17
+ * Everything a customer writes for their own plugin is here — a manifest, a handler factory, and the
18
+ * IAM the handler needs. `addAccessPortal({ plugins: [parametersPlugin(...)] })` is the only wiring. */
19
+ export declare function parametersPlugin(props: ParametersPluginProps): PluginDefinition;
20
+ export { assertInScope, configuredPrefixes, ParameterScopeError } from "./scope";
21
+ export type { ParameterDetail, ParameterSummary, ParameterStore } from "./scope";