Skip to content

Commit 49a831d

Browse files
committed
refactor(hosting): debrand secret engine + injectable namespace
Decouples the secret mechanism from Blocks branding so @aws-blocks/hosting stays a framework-neutral leaf that any consumer (a plain framework app, a future standalone hosting package) can use. No behavior change for Blocks users. @aws-blocks/hosting (neutral engine): - secretParameterName(key, prefix?) now takes an injectable SSM prefix, defaulting to a neutral DEFAULT_SECRET_PARAMETER_PREFIX = '/aws-hosting/secrets' (was hardcoded '/blocks/secrets'). - env var prefix renamed BLOCKS_SECRET_PARAM_ -> HOSTING_SECRET_PARAM_ (internal wiring, framework-neutral). - Stripped Blocks-specific wording from doc comments + runtime error messages. @aws-blocks/core (Blocks namespace): - New secret-naming.ts: BLOCKS_SECRET_PARAMETER_PREFIX='/blocks/secrets' + blocksSecretParameterName() = neutral engine + Blocks prefix. Single place Blocks pins its namespace. - hosting-secrets.ts (CDK wiring) and scripts/secret.ts (CLI) now route through blocksSecretParameterName(), so Blocks secrets still land at /blocks/secrets/<KEY> -> NO SSM-path migration, Blocks users unaffected. Why: making the engine brand-neutral now turns a future standalone-secret extraction into a mechanical move instead of a breaking SSM-path migration. Full extraction into a standalone package is deliberately deferred to the standalone-hosting work. Tests: neutral-default + injected-prefix + neutral-env assertions (hosting), new secret-naming test (core), updated CDK integration assertion to HOSTING_SECRET_PARAM_. hosting 708 / core 507 green.
1 parent 2635740 commit 49a831d

12 files changed

Lines changed: 138 additions & 52 deletions

File tree

.changeset/hosting-secrets.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ Introduces a `secret()` reference for sensitive values (API keys, third-party cr
99

1010
- **`secret('KEY')` marker** — exported from `@aws-blocks/hosting` (and re-exported from `@aws-blocks/core` / `@aws-blocks/blocks`). A dependency-free value object so it can later be shared with `@aws-blocks/pipeline` without a dependency cycle. Pass it into `Hosting` props.
1111
- **Two resolution strategies, chosen per prop:**
12-
- `compute.environment` values resolve at **runtime** (the secure default): the SSM parameter *name* (never the value) is injected as `BLOCKS_SECRET_PARAM_<KEY>`, the compute role is granted `ssm:GetParameter` (scoped to that one ARN) + `kms:Decrypt` (conditioned on `kms:ViaService`), and the value is fetched + decrypted on first use via `getSecret('KEY')`. The secret stays encrypted at rest and never enters the template.
12+
- `compute.environment` values resolve at **runtime** (the secure default): the SSM parameter *name* (never the value) is injected as `HOSTING_SECRET_PARAM_<KEY>`, the compute role is granted `ssm:GetParameter` (scoped to that one ARN) + `kms:Decrypt` (conditioned on `kms:ViaService`), and the value is fetched + decrypted on first use via `getSecret('KEY')`. The secret stays encrypted at rest and never enters the template.
1313
- `domain.domainName` and `secret(..., { exposeAsEnv: true })` resolve at **synth time** via an SDK `GetParameter(WithDecryption)` call and are inlined as literals. `ssm-secure` CloudFormation dynamic references are restricted to an allowlist that excludes CloudFront `Aliases` and Lambda env vars, so synth-time SDK resolution is the correct mechanism; a domain is public anyway. Synth-time resolution is async — use the new `await Hosting.create(scope, id, props)`.
1414
- **`getSecret('KEY')` runtime resolver** — reads `process.env.KEY` first (local dev / `exposeAsEnv`), else fetches + decrypts the injected SSM parameter, caching per cold start and coalescing concurrent calls. Mirrors the existing `AppSetting` / external-DB connection-string pattern.
1515
- **`secret` CLI** (`runSecretCli`, `setSecret`, `listSecrets`, `removeSecret`) — `blocks secret set/list/remove` manage SecureStrings at `/blocks/secrets/<KEY>` (flat namespace, 1:1 key↔reference, no stage scoping). `list` returns names only, never values. Wired into the Next.js template as `npm run secret`.
1616

17-
Path convention is centralized in `secretParameterName()` (`/blocks/secrets/<KEY>`), alongside the existing `/blocks/{stage}/db-connection-string`. Backward compatible: `new Hosting(...)` keeps working for plain config and runtime secrets; only synth-time secrets require `Hosting.create()`.
17+
**Framework-neutral by design (decoupling).** The secret *mechanism* in `@aws-blocks/hosting` carries no Blocks branding: it defaults to a neutral `/aws-hosting/secrets` prefix (`DEFAULT_SECRET_PARAMETER_PREFIX`) and a neutral `HOSTING_SECRET_PARAM_` env prefix, and `secretParameterName(key, prefix?)` takes an injectable namespace. Blocks pins its own `/blocks/secrets` namespace in `@aws-blocks/core` (`blocksSecretParameterName`, `BLOCKS_SECRET_PARAMETER_PREFIX`), so Blocks users see **no change** while a non-Blocks consumer (a plain framework app, or a future standalone hosting package) never inherits Blocks paths. This keeps `@aws-blocks/hosting` a zero-`@aws-blocks/*`-deps leaf and makes a future extraction of the secret engine into a standalone package a mechanical move rather than a breaking SSM-path migration (which is deliberately deferred to that extraction).
18+
19+
Path convention for Blocks is centralized in `blocksSecretParameterName()` (`/blocks/secrets/<KEY>`), alongside the existing `/blocks/{stage}/db-connection-string`. Backward compatible: `new Hosting(...)` keeps working for plain config and runtime secrets; only synth-time secrets require `Hosting.create()`.

packages/core/src/hosting-secrets.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@
2828
* @module
2929
*/
3030

31-
import { isSecret, type SecretValue, secretEnvVarName, secretParameterName } from '@aws-blocks/hosting';
31+
import { isSecret, type SecretValue, secretEnvVarName } from '@aws-blocks/hosting';
3232
import * as cdk from 'aws-cdk-lib';
3333
import * as iam from 'aws-cdk-lib/aws-iam';
34+
import { blocksSecretParameterName } from './secret-naming.js';
3435

3536
/** A `compute.environment` value: a literal, or a deferred secret reference. */
3637
export type EnvValue = string | SecretValue;
@@ -103,7 +104,7 @@ export async function resolveSecretsAtSynth(
103104
const resolved = new Map<string, string>();
104105
await Promise.all(
105106
keys.map(async (key) => {
106-
const name = secretParameterName(key);
107+
const name = blocksSecretParameterName(key);
107108
try {
108109
resolved.set(key, await fetcher(name));
109110
} catch (error: unknown) {
@@ -144,7 +145,7 @@ export function resolveDomainNames(domainName: DomainNameInput, resolved: Map<st
144145
* the compute role read+decrypt access scoped to that one parameter.
145146
*/
146147
export function wireRuntimeSecret(fn: cdk.aws_lambda.Function, key: string): void {
147-
const parameterName = secretParameterName(key);
148+
const parameterName = blocksSecretParameterName(key);
148149
fn.addEnvironment(secretEnvVarName(key), parameterName);
149150

150151
const parameterArn = cdk.Stack.of(fn).formatArn({

packages/core/src/hosting.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1531,11 +1531,13 @@ describe('Hosting', () => {
15311531

15321532
const template = Template.fromStack(stack);
15331533

1534-
// The compute Lambda gets the param NAME under BLOCKS_SECRET_PARAM_STRIPE_KEY.
1534+
// The compute Lambda gets the param NAME under the neutral
1535+
// HOSTING_SECRET_PARAM_ prefix; the Blocks-pinned /blocks/secrets path is
1536+
// the value (Blocks injects its own namespace over the neutral engine).
15351537
template.hasResourceProperties('AWS::Lambda::Function', {
15361538
Environment: {
15371539
Variables: Match.objectLike({
1538-
BLOCKS_SECRET_PARAM_STRIPE_KEY: '/blocks/secrets/STRIPE_KEY',
1540+
HOSTING_SECRET_PARAM_STRIPE_KEY: '/blocks/secrets/STRIPE_KEY',
15391541
}),
15401542
},
15411543
});

packages/core/src/index.cdk.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,14 @@ export {
2424
getSecret,
2525
secretParameterName,
2626
secretEnvVarName,
27-
SECRET_PARAMETER_PREFIX,
27+
DEFAULT_SECRET_PARAMETER_PREFIX,
2828
type SecretValue,
2929
type SecretOptions,
3030
} from '@aws-blocks/hosting';
31+
export {
32+
BLOCKS_SECRET_PARAMETER_PREFIX,
33+
blocksSecretParameterName,
34+
} from './secret-naming.js';
3135
export {
3236
RawRoute,
3337
RawRouteErrors,

packages/core/src/scripts/secret.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
* `blocks secret` — manage hosting/pipeline secrets in SSM Parameter Store.
66
*
77
* Secrets are stored as SecureString parameters under `/blocks/secrets/<KEY>`
8-
* (see {@link secretParameterName} — the single source of truth for the path).
8+
* (see {@link blocksSecretParameterName} — the single source of truth for the
9+
* Blocks path; the neutral engine lives in `@aws-blocks/hosting/secret`).
910
* This is the out-of-band counterpart to `secret('KEY')` in `hosting.ts`:
1011
* the customer sets values here once; the deploy only ever READS them.
1112
*
@@ -21,7 +22,8 @@
2122
* @module
2223
*/
2324

24-
import { SECRET_PARAMETER_PREFIX, secretEnvVarName, secretParameterName } from '@aws-blocks/hosting/secret';
25+
import { secretEnvVarName } from '@aws-blocks/hosting/secret';
26+
import { BLOCKS_SECRET_PARAMETER_PREFIX, blocksSecretParameterName } from '../secret-naming.js';
2527

2628
const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
2729

@@ -43,7 +45,7 @@ export async function setSecret(key: string, value: string): Promise<void> {
4345
}
4446
const { SSMClient, PutParameterCommand } = await import('@aws-sdk/client-ssm');
4547
const client = new SSMClient({});
46-
const name = secretParameterName(key);
48+
const name = blocksSecretParameterName(key);
4749
await client.send(
4850
new PutParameterCommand({
4951
Name: name,
@@ -64,14 +66,14 @@ export async function listSecrets(): Promise<string[]> {
6466
do {
6567
const result = await client.send(
6668
new GetParametersByPathCommand({
67-
Path: SECRET_PARAMETER_PREFIX,
69+
Path: BLOCKS_SECRET_PARAMETER_PREFIX,
6870
Recursive: false,
6971
WithDecryption: false, // names only — never decrypt for a list
7072
NextToken: nextToken,
7173
}),
7274
);
7375
for (const p of result.Parameters ?? []) {
74-
if (p.Name) keys.push(p.Name.slice(SECRET_PARAMETER_PREFIX.length + 1));
76+
if (p.Name) keys.push(p.Name.slice(BLOCKS_SECRET_PARAMETER_PREFIX.length + 1));
7577
}
7678
nextToken = result.NextToken;
7779
} while (nextToken);
@@ -84,7 +86,7 @@ export async function removeSecret(key: string): Promise<boolean> {
8486
const { SSMClient, DeleteParameterCommand } = await import('@aws-sdk/client-ssm');
8587
const client = new SSMClient({});
8688
try {
87-
await client.send(new DeleteParameterCommand({ Name: secretParameterName(key) }));
89+
await client.send(new DeleteParameterCommand({ Name: blocksSecretParameterName(key) }));
8890
console.log(`🗑️ Secret '${key}' removed.`);
8991
return true;
9092
} catch (error: unknown) {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import assert from 'node:assert';
5+
import { describe, it } from 'node:test';
6+
import { secretParameterName } from '@aws-blocks/hosting/secret';
7+
import { BLOCKS_SECRET_PARAMETER_PREFIX, blocksSecretParameterName } from './secret-naming.js';
8+
9+
void describe('Blocks secret namespace', () => {
10+
void it('pins the Blocks /blocks/secrets prefix (unchanged behavior for Blocks users)', () => {
11+
assert.strictEqual(BLOCKS_SECRET_PARAMETER_PREFIX, '/blocks/secrets');
12+
assert.strictEqual(blocksSecretParameterName('STRIPE_KEY'), '/blocks/secrets/STRIPE_KEY');
13+
});
14+
15+
void it('is exactly the neutral engine + the Blocks prefix (no divergent logic)', () => {
16+
assert.strictEqual(
17+
blocksSecretParameterName('DOMAIN_PROD'),
18+
secretParameterName('DOMAIN_PROD', BLOCKS_SECRET_PARAMETER_PREFIX),
19+
);
20+
});
21+
});

packages/core/src/secret-naming.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/**
5+
* Blocks-owned SSM namespace for hosting secrets.
6+
*
7+
* The secret *mechanism* (marker, runtime resolver, path/env naming) lives in
8+
* the framework-neutral `@aws-blocks/hosting` package, which defaults to a
9+
* neutral `/aws-hosting/secrets` prefix so a non-Blocks consumer (a plain
10+
* framework app, a future standalone hosting package) never inherits Blocks
11+
* branding.
12+
*
13+
* This module is the ONE place Blocks pins its own namespace. Every Blocks-side
14+
* caller — the `secret` CLI (`scripts/secret.ts`) and the CDK wiring
15+
* (`hosting-secrets.ts`) — routes through {@link blocksSecretParameterName} so
16+
* Blocks secrets consistently land at `/blocks/secrets/<KEY>`, alongside the
17+
* existing `/blocks/{stage}/db-connection-string` convention. Keeping the prefix
18+
* here (not in the hosting leaf) is what makes the future extraction of the
19+
* secret mechanism into a standalone package a mechanical move rather than a
20+
* breaking SSM-path migration.
21+
*
22+
* @module
23+
*/
24+
25+
import { secretParameterName } from '@aws-blocks/hosting/secret';
26+
27+
/** Blocks SSM prefix for hosting secrets. Blocks pins `/blocks`; the leaf stays neutral. */
28+
export const BLOCKS_SECRET_PARAMETER_PREFIX = '/blocks/secrets';
29+
30+
/**
31+
* Blocks-namespaced SSM parameter name for a secret key.
32+
* Thin wrapper over the neutral {@link secretParameterName} that always injects
33+
* the Blocks prefix, so callers can't accidentally use the neutral default.
34+
*
35+
* @example blocksSecretParameterName('STRIPE_KEY') // '/blocks/secrets/STRIPE_KEY'
36+
*/
37+
export function blocksSecretParameterName(key: string): string {
38+
return secretParameterName(key, BLOCKS_SECRET_PARAMETER_PREFIX);
39+
}

packages/hosting/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export {
2727
secretParameterName,
2828
secretEnvVarName,
2929
SECRET_BRAND,
30-
SECRET_PARAMETER_PREFIX,
30+
DEFAULT_SECRET_PARAMETER_PREFIX,
3131
type SecretValue,
3232
type SecretOptions,
3333
} from './secret.js';

packages/hosting/src/secret-exports.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
export {
1616
isSecret,
1717
SECRET_BRAND,
18-
SECRET_PARAMETER_PREFIX,
18+
DEFAULT_SECRET_PARAMETER_PREFIX,
1919
type SecretOptions,
2020
type SecretValue,
2121
secret,

packages/hosting/src/secret-runtime.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* Resolution order for a key:
1111
* 1. `process.env[KEY]` — local dev (and the `exposeAsEnv` escape hatch),
1212
* where the plaintext is already present. No AWS call, works offline.
13-
* 2. `process.env[BLOCKS_SECRET_PARAM_<KEY>]` — the SSM parameter NAME the
13+
* 2. `process.env[HOSTING_SECRET_PARAM_<KEY>]` — the SSM parameter NAME the
1414
* Hosting wiring injected. Fetch + decrypt via SSM, then cache.
1515
*
1616
* The value is held only in process memory and only after the first call;
@@ -44,8 +44,8 @@ async function defaultSsmFetcher(parameterName: string): Promise<string> {
4444
const value = result.Parameter?.Value;
4545
if (value === undefined || value === null) {
4646
throw new Error(
47-
`[Blocks] Secret parameter "${parameterName}" exists but has no value. ` +
48-
`Set it with: blocks secret set <KEY> <value>`,
47+
`[hosting] Secret parameter "${parameterName}" exists but has no value. ` +
48+
`Set it with your secret CLI (e.g. \`secret set <KEY> <value>\`).`,
4949
);
5050
}
5151
return value;
@@ -75,9 +75,10 @@ export async function getSecret(key: string): Promise<string> {
7575
const parameterName = process.env[secretEnvVarName(key)];
7676
if (!parameterName) {
7777
throw new Error(
78-
`[Blocks] getSecret(${JSON.stringify(key)}): no secret reference found. ` +
78+
`[hosting] getSecret(${JSON.stringify(key)}): no secret reference found. ` +
7979
`Reference it in Hosting props with secret(${JSON.stringify(key)}) so the ` +
80-
`parameter is wired, and set its value with: blocks secret set ${key} <value>`,
80+
`parameter is wired, and set its value with your secret CLI ` +
81+
`(e.g. \`secret set ${key} <value>\`).`,
8182
);
8283
}
8384

0 commit comments

Comments
 (0)