@aws-blocks/bb-app-setting 0.1.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 (43) hide show
  1. package/LICENSE +174 -0
  2. package/README.md +156 -0
  3. package/dist/errors.d.ts +25 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/errors.js +26 -0
  6. package/dist/index.aws.d.ts +76 -0
  7. package/dist/index.aws.d.ts.map +1 -0
  8. package/dist/index.aws.js +135 -0
  9. package/dist/index.browser.d.ts +5 -0
  10. package/dist/index.browser.d.ts.map +1 -0
  11. package/dist/index.browser.js +9 -0
  12. package/dist/index.cdk.d.ts +35 -0
  13. package/dist/index.cdk.d.ts.map +1 -0
  14. package/dist/index.cdk.js +242 -0
  15. package/dist/index.cdk.test.d.ts +2 -0
  16. package/dist/index.cdk.test.d.ts.map +1 -0
  17. package/dist/index.cdk.test.js +187 -0
  18. package/dist/index.hooks.d.ts +2 -0
  19. package/dist/index.hooks.d.ts.map +1 -0
  20. package/dist/index.hooks.js +3 -0
  21. package/dist/index.mock.d.ts +77 -0
  22. package/dist/index.mock.d.ts.map +1 -0
  23. package/dist/index.mock.js +153 -0
  24. package/dist/index.test.d.ts +2 -0
  25. package/dist/index.test.d.ts.map +1 -0
  26. package/dist/index.test.js +542 -0
  27. package/dist/types.d.ts +52 -0
  28. package/dist/types.d.ts.map +1 -0
  29. package/dist/types.js +3 -0
  30. package/dist/version.d.ts +3 -0
  31. package/dist/version.d.ts.map +1 -0
  32. package/dist/version.js +3 -0
  33. package/package.json +46 -0
  34. package/src/errors.ts +27 -0
  35. package/src/index.aws.ts +165 -0
  36. package/src/index.browser.ts +10 -0
  37. package/src/index.cdk.test.ts +227 -0
  38. package/src/index.cdk.ts +291 -0
  39. package/src/index.hooks.ts +5 -0
  40. package/src/index.mock.ts +181 -0
  41. package/src/index.test.ts +627 -0
  42. package/src/types.ts +56 -0
  43. package/src/version.ts +3 -0
@@ -0,0 +1,291 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import * as cdk from 'aws-cdk-lib';
5
+ import * as ssm from 'aws-cdk-lib/aws-ssm';
6
+ import * as iam from 'aws-cdk-lib/aws-iam';
7
+ import * as lambda from 'aws-cdk-lib/aws-lambda';
8
+ import * as cr from 'aws-cdk-lib/custom-resources';
9
+ import { Scope, registerConfig, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
10
+ import type { ScopeParent } from '@aws-blocks/core';
11
+ import { AppSettingErrors } from './errors.js';
12
+ import type { AppSettingOptions, InternalAppSettingOptions } from './types.js';
13
+
14
+ export { AppSettingErrors } from './errors.js';
15
+ export type { AppSettingOptions } from './types.js';
16
+
17
+ /**
18
+ * CDK construct for AppSetting. Creates a single SSM parameter (String or
19
+ * SecureString) and grants the shared Lambda handler read/write permissions.
20
+ *
21
+ * - String parameters use `aws-cdk-lib/aws-ssm.StringParameter` directly.
22
+ * - SecureString parameters use a Custom Resource Lambda because
23
+ * CloudFormation cannot natively create SecureString parameters.
24
+ * - SecureString parameters are encrypted with the default `aws/ssm` KMS key.
25
+ */
26
+ export class AppSetting<T = string> extends Scope {
27
+ /**
28
+ * Reference an SSM parameter that is created and owned **outside this stack**
29
+ * (e.g. a connection string seeded by `ensureSecrets` before deploy). The
30
+ * construct does not create, seed, tag, or delete it — it only grants the app
31
+ * **read-only** access (`ssm:GetParameter`, plus `kms:Decrypt` when `secret`)
32
+ * and registers the name for config resolution.
33
+ *
34
+ * The parameter MUST already exist at deploy time, otherwise the app fails at
35
+ * runtime with `ParameterNotFound`.
36
+ *
37
+ * @example
38
+ * const dbUrl = AppSetting.fromExisting(scope, 'db-url', { name: dbParameterName, secret: true });
39
+ */
40
+ static fromExisting<T = string>(
41
+ scope: ScopeParent,
42
+ id: string,
43
+ options: { name: string; secret?: boolean },
44
+ ): AppSetting<T> {
45
+ const opts: InternalAppSettingOptions<T> = { ...options, external: true };
46
+ return new AppSetting<T>(scope, id, opts);
47
+ }
48
+
49
+ constructor(scope: ScopeParent, id: string, options: AppSettingOptions<T>) {
50
+ super(id, { parent: scope });
51
+
52
+ // `external` is package-internal (set only by fromExisting), not on the
53
+ // public AppSettingOptions — read it via the internal options type.
54
+ const external = (options as InternalAppSettingOptions<T>).external ?? false;
55
+
56
+ // ── Validation ──────────────────────────────────────────────────────
57
+ if (options.secret && options.schema) {
58
+ const err = new Error(
59
+ `AppSetting '${id}': 'secret' and 'schema' cannot be used together. ` +
60
+ `Secrets are always plain strings. Remove the schema or the secret flag.`
61
+ );
62
+ err.name = AppSettingErrors.ValidationFailed;
63
+ throw err;
64
+ }
65
+
66
+ if (options.schema && options.value === undefined) {
67
+ const err = new Error(
68
+ `AppSetting '${id}': a schema is provided but no value. ` +
69
+ `Provide a value that conforms to the schema so the SSM parameter is valid on first deploy.`
70
+ );
71
+ err.name = AppSettingErrors.ValidationFailed;
72
+ throw err;
73
+ }
74
+
75
+ if (options.secret && options.value !== undefined) {
76
+ const err = new Error(
77
+ `AppSetting '${id}': secrets should not have a value in source code. ` +
78
+ `Remove the value — a random secret will be generated on first deploy. ` +
79
+ `Set the real value at runtime via AppSetting.put().`
80
+ );
81
+ err.name = AppSettingErrors.ValidationFailed;
82
+ throw err;
83
+ }
84
+
85
+ if (external && options.value !== undefined) {
86
+ const err = new Error(
87
+ `AppSetting '${id}': 'external' settings are owned elsewhere and must not have a value. ` +
88
+ `Remove the value — the parameter is created and seeded outside this stack.`
89
+ );
90
+ err.name = AppSettingErrors.ValidationFailed;
91
+ throw err;
92
+ }
93
+
94
+ if (external && !options.name) {
95
+ const err = new Error(
96
+ `AppSetting '${id}': 'external' requires an explicit 'name' referencing the existing parameter.`
97
+ );
98
+ err.name = AppSettingErrors.ValidationFailed;
99
+ throw err;
100
+ }
101
+
102
+ if (!options.secret && !external && options.value === undefined) {
103
+ const err = new Error(
104
+ `AppSetting '${id}': non-secret settings require a value. ` +
105
+ `Provide an initial value for the SSM parameter.`
106
+ );
107
+ err.name = AppSettingErrors.ValidationFailed;
108
+ throw err;
109
+ }
110
+
111
+ const parameterName = options.name ?? `/${this.fullId}`;
112
+
113
+ // Always JSON.stringify
114
+ // For secrets without a value, the Custom Resource Lambda generates a random string
115
+ const initialValue = options.value !== undefined
116
+ ? JSON.stringify(options.value)
117
+ : undefined;
118
+
119
+ const parameterArn = cdk.Stack.of(this).formatArn({
120
+ service: 'ssm',
121
+ resource: 'parameter',
122
+ resourceName: parameterName.replace(/^\//, ''),
123
+ });
124
+
125
+ if (options.secret) {
126
+ // ── SecureString ────────────────────────────────────────────────
127
+ // Externally-owned secrets (e.g. the connection string seeded by
128
+ // ensureSecrets) are NOT enrolled in the bulk-init: it would
129
+ // PutParameter a random placeholder over a parameter we don't own and
130
+ // then fail tagging it (AddTagsToResource needs ssm:GetParameters).
131
+ // We only need runtime read access, granted below.
132
+ if (!external) {
133
+ registerSecret(cdk.Stack.of(this), parameterName);
134
+ }
135
+
136
+ // Grant handler KMS access for the default aws/ssm key. External secrets
137
+ // are read-only (Decrypt only); stack-managed secrets also need Encrypt
138
+ // so the app can write the value via put().
139
+ this.handler.addToRolePolicy(new iam.PolicyStatement({
140
+ actions: external ? ['kms:Decrypt'] : ['kms:Decrypt', 'kms:Encrypt'],
141
+ resources: ['*'],
142
+ conditions: {
143
+ StringEquals: {
144
+ 'kms:ViaService': `ssm.${cdk.Stack.of(this).region}.amazonaws.com`,
145
+ },
146
+ },
147
+ }));
148
+ } else if (!external) {
149
+ // ── String parameter via CDK construct ──────────────────────────
150
+ const param = new ssm.StringParameter(this, 'Param', {
151
+ parameterName,
152
+ stringValue: initialValue ?? '',
153
+ });
154
+ let tagStack = cdk.Stack.of(this);
155
+ while (tagStack.nestedStackParent) tagStack = tagStack.nestedStackParent;
156
+ cdk.Tags.of(param).add('aws-blocks-stack', tagStack.stackName);
157
+ }
158
+ // (external non-secret: parameter exists already; nothing to create.)
159
+
160
+ // Grant handler SSM access on this parameter. External parameters are owned
161
+ // elsewhere, so the app only reads them (no ssm:PutParameter).
162
+ this.handler.addToRolePolicy(new iam.PolicyStatement({
163
+ actions: external ? ['ssm:GetParameter'] : ['ssm:GetParameter', 'ssm:PutParameter'],
164
+ resources: [parameterArn],
165
+ }));
166
+
167
+ // Pass the parameter name to the runtime via config registry
168
+ const envKey = `BLOCKS_SSM_PARAM_${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
169
+ registerConfig(this, envKey, parameterName);
170
+ }
171
+ }
172
+
173
+ // ── Bulk Secret Initialization (one CustomResource per stack) ───────────────
174
+
175
+ const SECRET_BULK_KEY = Symbol.for('BLOCKS_SECRET_BULK_INIT');
176
+
177
+ interface SecretBulkState {
178
+ parameterNames: string[];
179
+ }
180
+
181
+ /**
182
+ * Register a secret parameter name. On first call, creates the shared Lambda,
183
+ * Provider, and a single CustomResource. All subsequent calls just append to
184
+ * the parameter list (resolved lazily at synth time).
185
+ */
186
+ function registerSecret(stack: cdk.Stack, parameterName: string): void {
187
+ let state = (stack as any)[SECRET_BULK_KEY] as SecretBulkState | undefined;
188
+ if (state) {
189
+ state.parameterNames.push(parameterName);
190
+ return;
191
+ }
192
+
193
+ // First secret in this stack — create all shared infrastructure
194
+ state = { parameterNames: [parameterName] };
195
+ (stack as any)[SECRET_BULK_KEY] = state;
196
+
197
+ const secretInitFn = new lambda.Function(stack, 'KitSecretInitFn', {
198
+ runtime: DEFAULT_NODE_RUNTIME,
199
+ handler: 'index.handler',
200
+ code: lambda.Code.fromInline(`
201
+ const { SSMClient, PutParameterCommand, DeleteParameterCommand, AddTagsToResourceCommand } = require('@aws-sdk/client-ssm');
202
+ const crypto = require('crypto');
203
+ const client = new SSMClient({});
204
+ exports.handler = async (event) => {
205
+ const names = event.ResourceProperties.ParameterNames || [];
206
+ const stackName = event.ResourceProperties.StackName || '';
207
+ const tags = stackName ? [{ Key: 'aws-blocks-stack', Value: stackName }] : [];
208
+ const oldNames = (event.OldResourceProperties || {}).ParameterNames || [];
209
+ if (event.RequestType === 'Delete') {
210
+ for (const name of names) {
211
+ try { await client.send(new DeleteParameterCommand({ Name: name })); } catch {}
212
+ }
213
+ return { PhysicalResourceId: 'bb-secrets-bulk' };
214
+ }
215
+ if (event.RequestType === 'Create') {
216
+ for (const name of names) {
217
+ const secret = crypto.randomBytes(32).toString('base64url');
218
+ try {
219
+ await client.send(new PutParameterCommand({
220
+ Name: name, Value: secret, Type: 'SecureString', Overwrite: false, Tags: tags,
221
+ }));
222
+ } catch (e) {
223
+ if (e.name !== 'ParameterAlreadyExists') throw e;
224
+ if (tags.length) {
225
+ await client.send(new AddTagsToResourceCommand({ ResourceType: 'Parameter', ResourceId: name, Tags: tags }));
226
+ }
227
+ }
228
+ }
229
+ return { PhysicalResourceId: 'bb-secrets-bulk' };
230
+ }
231
+ if (event.RequestType === 'Update') {
232
+ const added = names.filter(n => !oldNames.includes(n));
233
+ const removed = oldNames.filter(n => !names.includes(n));
234
+ for (const name of added) {
235
+ const secret = crypto.randomBytes(32).toString('base64url');
236
+ try {
237
+ await client.send(new PutParameterCommand({
238
+ Name: name, Value: secret, Type: 'SecureString', Overwrite: false, Tags: tags,
239
+ }));
240
+ } catch (e) {
241
+ if (e.name !== 'ParameterAlreadyExists') throw e;
242
+ if (tags.length) {
243
+ await client.send(new AddTagsToResourceCommand({ ResourceType: 'Parameter', ResourceId: name, Tags: tags }));
244
+ }
245
+ }
246
+ }
247
+ for (const name of removed) {
248
+ try { await client.send(new DeleteParameterCommand({ Name: name })); } catch {}
249
+ }
250
+ return { PhysicalResourceId: 'bb-secrets-bulk' };
251
+ }
252
+ return { PhysicalResourceId: 'bb-secrets-bulk' };
253
+ };
254
+ `),
255
+ });
256
+
257
+ secretInitFn.addToRolePolicy(new iam.PolicyStatement({
258
+ actions: ['ssm:PutParameter', 'ssm:DeleteParameter', 'ssm:AddTagsToResource'],
259
+ resources: cdk.Lazy.list({
260
+ produce: () => state!.parameterNames.map(name =>
261
+ stack.formatArn({
262
+ service: 'ssm',
263
+ resource: 'parameter',
264
+ resourceName: name.replace(/^\//, ''),
265
+ })
266
+ ),
267
+ }),
268
+ }));
269
+
270
+ secretInitFn.addToRolePolicy(new iam.PolicyStatement({
271
+ actions: ['kms:Encrypt'],
272
+ resources: ['*'],
273
+ conditions: {
274
+ StringEquals: {
275
+ 'kms:ViaService': `ssm.${stack.region}.amazonaws.com`,
276
+ },
277
+ },
278
+ }));
279
+
280
+ const provider = new cr.Provider(stack, 'KitSecretProvider', {
281
+ onEventHandler: secretInitFn,
282
+ });
283
+
284
+ new cdk.CustomResource(stack, 'KitSecretsBulk', {
285
+ serviceToken: provider.serviceToken,
286
+ properties: {
287
+ ParameterNames: cdk.Lazy.list({ produce: () => state!.parameterNames }),
288
+ StackName: (() => { let s = stack; while (s.nestedStackParent) s = s.nestedStackParent; return s.stackName; })(),
289
+ },
290
+ });
291
+ }
@@ -0,0 +1,5 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // No client protocol needed
5
+ export {};
@@ -0,0 +1,181 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { Scope, registerSdkIdentifiers } from '@aws-blocks/core';
5
+ import { getMockDataDir } from '@aws-blocks/core/bb-utils';
6
+ import type { ScopeParent } from '@aws-blocks/core';
7
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
8
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { randomBytes } from 'node:crypto';
11
+ import { AppSettingErrors } from './errors.js';
12
+ import type { AppSettingOptions, InternalAppSettingOptions } from './types.js';
13
+ import { Logger } from '@aws-blocks/bb-logger';
14
+ import type { ChildLogger } from '@aws-blocks/bb-logger';
15
+ import { BB_NAME, BB_VERSION } from './version.js';
16
+
17
+ // Re-export public types
18
+ export { AppSettingErrors } from './errors.js';
19
+ export type { AppSettingOptions } from './types.js';
20
+
21
+ // ── Helpers ─────────────────────────────────────────────────────────────────
22
+
23
+ const MAX_VALUE_BYTES = 4096; // SSM standard-tier 4 KB limit
24
+
25
+ function readSettings(scope: Scope): Record<string, unknown> {
26
+ const fp = join(getMockDataDir(scope, { root: true }), 'settings.json');
27
+ if (!existsSync(fp)) return {};
28
+ try {
29
+ return JSON.parse(readFileSync(fp, 'utf8'));
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ function writeSettings(scope: Scope, data: Record<string, unknown>): void {
36
+ const fp = join(getMockDataDir(scope, { root: true }), 'settings.json');
37
+ writeFileSync(fp, JSON.stringify(data, null, 2));
38
+ }
39
+
40
+ function blocksError(name: string, message: string): Error {
41
+ const err = new Error(`${name}: ${message}`);
42
+ err.name = name;
43
+ return err;
44
+ }
45
+
46
+ async function validateSchema<T>(schema: StandardSchemaV1<T> | undefined, value: unknown): Promise<void> {
47
+ if (!schema) return;
48
+ const result = schema['~standard'].validate(value);
49
+ const resolved = result instanceof Promise ? await result : result;
50
+ if (resolved.issues) {
51
+ throw blocksError(AppSettingErrors.ValidationFailed, resolved.issues[0].message);
52
+ }
53
+ }
54
+
55
+ // ── AppSetting (mock) ───────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * A single application configuration value backed by SSM Parameter Store.
59
+ *
60
+ * **When to use:** You need to store and retrieve a non-secret configuration
61
+ * value at runtime — a feature flag, API URL, threshold, or structured config
62
+ * object. For sensitive values, set `secret: true` to use SSM SecureString.
63
+ *
64
+ * **When NOT to use:** If you need structured key-value data with conditional
65
+ * writes and queries, use `KVStore` or `DistributedTable`.
66
+ *
67
+ * **Best practices:**
68
+ * - One AppSetting per logical configuration value
69
+ * - Use a schema for structured objects to get type safety and runtime validation
70
+ * - Use `secret: true` for API keys, tokens, and passwords
71
+ *
72
+ * **Scaling:** Standard-tier SSM parameters. 40 TPS default for GetParameter
73
+ * (can be increased). No cost for standard parameters.
74
+ */
75
+ export class AppSetting<T = string> extends Scope {
76
+ /**
77
+ * Reference an SSM parameter created and owned outside this stack — the local
78
+ * dev mirror of the CDK `fromExisting`. In the mock there is no IAM or
79
+ * bulk-init, so it behaves like a normal setting keyed by its `fullId`; the
80
+ * factory exists so app code uses the same API in dev and deploy.
81
+ *
82
+ * Note: like any mock secret with no value, `get()` returns a random placeholder
83
+ * unless `.bb-data/settings.json` was already seeded for this `fullId` (e.g. by
84
+ * `db pull`). Local dev still depends on whatever seeds that value.
85
+ */
86
+ static fromExisting<T = string>(
87
+ scope: ScopeParent,
88
+ id: string,
89
+ options: { name: string; secret?: boolean },
90
+ ): AppSetting<T> {
91
+ const opts: InternalAppSettingOptions<T> = { ...options, external: true };
92
+ return new AppSetting<T>(scope, id, opts);
93
+ }
94
+
95
+ private parameterName: string;
96
+ private initialValue: T;
97
+ private schema?: StandardSchemaV1<T>;
98
+ private isSecret: boolean;
99
+
100
+ /** @internal Logger for internal operations. Defaults to error-level when not provided. */
101
+ protected log: ChildLogger;
102
+
103
+ constructor(scope: ScopeParent, id: string, options: AppSettingOptions<T>) {
104
+ super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
105
+ this.log = options?.logger ?? new Logger(this, 'logger', { level: 'error' });
106
+ this.parameterName = options.name ?? `/${this.fullId}`;
107
+ this.schema = options.schema;
108
+ this.isSecret = options.secret ?? false;
109
+
110
+ // Determine initial value: use provided value, or generate random for secrets, or empty string
111
+ if (options.value !== undefined) {
112
+ this.initialValue = options.value;
113
+ } else if (this.isSecret) {
114
+ this.initialValue = randomBytes(32).toString('base64url') as T;
115
+ } else {
116
+ this.initialValue = '' as T;
117
+ }
118
+ registerSdkIdentifiers(this.fullId, { parameterName: this.parameterName });
119
+
120
+ // Persist initial value to settings.json if not already present
121
+ const settings = readSettings(this);
122
+ if (!(this.fullId in settings)) {
123
+ settings[this.fullId] = this.initialValue;
124
+ writeSettings(this, settings);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Retrieve the current value.
130
+ *
131
+ * Returns the stored value from `.bb-data/settings.json`.
132
+ *
133
+ * @returns The current value.
134
+ *
135
+ * @example
136
+ * ```typescript
137
+ * const retries = await maxRetries.get();
138
+ * ```
139
+ */
140
+ async get(): Promise<T> {
141
+ const settings = readSettings(this);
142
+ const value = (this.fullId in settings ? settings[this.fullId] : this.initialValue) as T;
143
+
144
+ if (this.isSecret && value === ('' as unknown as T)) {
145
+ throw blocksError(AppSettingErrors.ParameterNotFound, `Secret parameter "${this.parameterName}" has an empty value — secrets must not be empty`);
146
+ }
147
+
148
+ return value;
149
+ }
150
+
151
+ /**
152
+ * Update the value at runtime.
153
+ *
154
+ * Writes the new value to `.bb-data/settings.json`. When a schema is
155
+ * configured, the value is validated before writing.
156
+ *
157
+ * @param value - The new value to store.
158
+ * @throws {AppSettingErrors.ValidationFailed} If schema validation fails or value exceeds 4 KB.
159
+ *
160
+ * @example
161
+ * ```typescript
162
+ * await maxRetries.put('5');
163
+ * await config.put({ maxRetries: 5, timeout: 10000 });
164
+ * ```
165
+ */
166
+ async put(value: T): Promise<void> {
167
+ await validateSchema(this.schema, value);
168
+
169
+ const serialized = JSON.stringify(value);
170
+ if (!this.isSecret && Buffer.byteLength(serialized, 'utf8') > MAX_VALUE_BYTES) {
171
+ throw blocksError(
172
+ AppSettingErrors.ValidationFailed,
173
+ `Value size ${Buffer.byteLength(serialized, 'utf8')} bytes exceeds the 4 KB (${MAX_VALUE_BYTES} bytes) limit`,
174
+ );
175
+ }
176
+
177
+ const settings = readSettings(this);
178
+ settings[this.fullId] = value;
179
+ writeSettings(this, settings);
180
+ }
181
+ }