Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2635740
feat(hosting): add secret() for self-hosted deployments
osama-rizk Jul 1, 2026
49a831d
refactor(hosting): debrand secret engine + injectable namespace
osama-rizk Jul 1, 2026
67d173a
Merge branch 'main' into feat/hosting-secrets
osama-rizk Jul 6, 2026
c9792e1
feat(hosting): scaffold the secret CLI in all deploy templates
osama-rizk Jul 1, 2026
3ec6671
feat(hosting): make the L3 construct secret()-aware + pluggable store
osama-rizk Jul 7, 2026
7c972a9
feat(pipeline): support secret() for source.connectionArn
osama-rizk Jul 7, 2026
aafc473
chore: update lockfile for hosting/pipeline secret deps
osama-rizk Jul 7, 2026
46a45b0
docs(changeset): add pipeline + construct-level secrets; fix prefix note
osama-rizk Jul 7, 2026
8765e61
docs(changeset): cover create-blocks-app template scaffolding
osama-rizk Jul 7, 2026
6c40ce8
chore(core): regenerate API report for secret() exports
osama-rizk Jul 7, 2026
95396e2
Merge branch 'main' into feat/hosting-secrets
osama-rizk Jul 14, 2026
62ae200
feat(hosting): default secret() store to Secrets Manager for auto-rot…
osama-rizk Jul 14, 2026
dbd82b3
revert(hosting): default secret() store back to SSM; keep Secrets Man…
osama-rizk Jul 15, 2026
11f3d39
feat(hosting): promote default secret() store to Secrets Manager (pub…
osama-rizk Jul 16, 2026
27d3f1b
Merge branch 'main' into feat/hosting-secrets
osama-rizk Jul 16, 2026
8304a0e
Merge branch 'main' into feat/hosting-secrets
osama-rizk Jul 16, 2026
dcfa03e
feat(hosting): per-stage secrets with shared fallback
osama-rizk Jul 20, 2026
5e0d358
docs(hosting): keep secret engine framework-neutral in comments + cha…
osama-rizk Jul 20, 2026
e8969ca
feat(pipeline): buildSecrets — build-time secrets for CodeBuild commands
osama-rizk Jul 20, 2026
e3d8dce
Merge branch 'main' into feat/hosting-secrets
osama-rizk Jul 20, 2026
822a568
fix(pipeline): thread secrets prop into connectionArn resolution + do…
osama-rizk Jul 21, 2026
5380cfa
docs(core): clarify Blocks secret CLI is intentionally SSM (leaf defa…
osama-rizk Jul 21, 2026
3cf3c2a
refactor(pipeline): drop editor reformat churn from secrets diff
osama-rizk Jul 21, 2026
b7458c4
fix(hosting): address review — IAM policy hygiene + secret CLI/error …
osama-rizk Jul 22, 2026
cce5070
feat(hosting): secure secret CLI input + optional runtime cache TTL
osama-rizk Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/hosting-secrets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@aws-blocks/hosting": minor
"@aws-blocks/core": minor
"@aws-blocks/pipeline": minor
"@aws-blocks/create-blocks-app": patch
---

Add `secret()` support to hosting for self-hosted deployments.

Introduces a `secret()` reference for sensitive values (API keys, third-party credentials, custom domains) backed by a managed secret store — so they're never hardcoded in source, committed to git, or written into the CloudFormation template. The default store is **AWS Secrets Manager**, following AWS's public guidance ["How to choose the right AWS service for managing secrets and configurations"](https://aws.amazon.com/blogs/security/how-to-choose-the-right-aws-service-for-managing-secrets-and-configurations/), which recommends Secrets Manager for application secrets and credentials (API tokens, database passwords) — for its automatic rotation, multi-Region replication, and native service integrations — and Parameter Store for non-sensitive configuration. **SSM Parameter Store SecureString** remains a first-class opt-in (`store: 'ssm'`) for non-sensitive config where free scale-to-zero storage matters.

- **`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.
- **Two resolution strategies, chosen per prop:**
- `compute.environment` values resolve at **runtime** (the secure default): the store *locator* (never the value) is injected as `HOSTING_SECRET_PARAM_<KEY>` (plus a `_STORE` hint when not SSM), the compute role is granted read on that one resource (`secretsmanager:GetSecretValue` or `ssm:GetParameter`, scoped to the exact 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.
- `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)`.
- **`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. An optional `secrets.cacheTtlSeconds` injects `HOSTING_SECRET_CACHE_TTL` so a warm compute re-fetches after the TTL and picks up a rotated value without waiting for a cold start (default: cache for the process lifetime, unchanged).
- **`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`. To keep the value out of `argv`/shell history, `set` accepts `--value-stdin` (pipe the value) or, with no value on the command line, prompts for it interactively with the input hidden; a positional value still works for trusted CI.

**Works for any consumer of the L3 construct.** Runtime `secret()` markers are resolved inside the shared `HostingConstruct` itself (not just `core.Hosting`), so a standalone CDK app that uses the construct directly gets runtime secrets with no extra plumbing: `HostingConstruct.environment` accepts markers and a `secrets` prop selects the prefix/store.

**Pipeline.** `@aws-blocks/pipeline` supports `secret()` in two places, for its two consumers:
- `source.connectionArn` — consumed by the **CodePipeline service** to authenticate to the repo, so it's resolved at **synth time** from the store (the service needs a literal ARN in the template). Use `await Pipeline.create(...)` for the marker path.
- `buildSecrets: { NAME: secret('NAME') }` — credentials your **build/deploy commands** need (`npm publish`, `docker login`, etc.), injected into the synth CodeBuild project as `SECRETS_MANAGER`/`PARAMETER_STORE` environment variables. CodeBuild fetches them **at build time** (never inlined into the template, masked in build logs, build role auto-granted read), so they rotate with **no redeploy**. A `secrets: { prefix?, store? }` prop selects the namespace/store.

**Pluggable store (default: Secrets Manager; SSM opt-in).** `secret()` values are backed by **AWS Secrets Manager** by default (`DEFAULT_SECRET_STORE = 'secrets-manager'`), per the AWS guidance above. **SSM Parameter Store SecureString** (`store: 'ssm'`) stays a first-class opt-in for non-sensitive config. The store is a single-constant seam (`DEFAULT_SECRET_STORE`) — either store is a per-consumer option, not a rewrite. The store-appropriate name is produced by a single `secretStoreLocator()` helper so the CLI write, the IAM grant, and the runtime read always agree (SM secret names are slash-free at the root; SSM keeps the leading-slash path form). The runtime resolver selects the store from the injected `_STORE` hint, defaulting to SSM when absent — so `@aws-blocks/core` (Blocks), which pins its own SSM namespace and injects no hint, is unaffected by this default.

**Store-migration note.** SSM Parameter Store and Secrets Manager are distinct services with distinct resources; a value in one is not visible to the other. So if an app changes its `store` (or picked up secrets under a pre-release SSM default), existing secrets are **not** copied automatically — each must be re-set once via `secret set <KEY> <value>` (which then writes to the selected store). Apps that pin an explicit `store` stay put across upgrades.

**Per-stage secrets with shared fallback (optional).** A secret can hold a different value per environment. Passing a `stage` (via the `secrets` prop / resolver options, or `secret set <KEY> <value> --stage <name>`) resolves `<prefix>/<stage>/<key>` and **falls back** to the shared `<prefix>/<key>` when the stage has no value set — so `prod` and `beta` can differ while ephemeral PR/preview stages inherit a shared default, and a truly-shared secret is set once. It's fully **opt-in and backward-compatible**: omit `stage` and resolution is the flat single-namespace behavior, unchanged. For runtime secrets the fallback is a **two-try at request time** (the compute is wired with both the stage locator and a `_FALLBACK` shared locator, and granted read on both ARNs), so a stage value can be added/rotated without a redeploy; synth-time secrets (domains, `connectionArn`) resolve the fallback eagerly. `list` is stage-scoped when a stage is given. Because the shared-fallback grant is standing IAM (not enforced by the two-try logic), the shared slot should hold a **safe default for all stages** (e.g. a sandbox credential), never a production secret — give production its own stage-scoped value. The `kms:Decrypt` grant is emitted **once per compute function per store** (not per secret), keeping the inline role policy compact when several secrets are wired.

**Framework-neutral by design (decoupling).** The secret *mechanism* in `@aws-blocks/hosting` carries no Blocks branding: it defaults to a neutral `/hosting/secrets` prefix (`DEFAULT_SECRET_PARAMETER_PREFIX` — must not start with `aws`/`ssm`, which SSM reserves) 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).

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()`.
6 changes: 5 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions packages/core/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

```ts

import { getSecret } from '@aws-blocks/hosting/secret';
import { isSecret } from '@aws-blocks/hosting/secret';
import { secret } from '@aws-blocks/hosting/secret';
import { secretEnvVarName } from '@aws-blocks/hosting/secret';
import { SecretOptions } from '@aws-blocks/hosting/secret';
import { secretParameterName } from '@aws-blocks/hosting/secret';
import { SecretValue } from '@aws-blocks/hosting/secret';

// @public
export class ApiError extends Error {
constructor(message: string, status: number, options?: {
Expand Down Expand Up @@ -78,6 +86,8 @@ export function getSdkIdentifiers(bb: {
fullId: string;
}): Record<string, string>;

export { getSecret }

// @public
export function hasAuthError<T extends {
errorName?: string;
Expand All @@ -93,6 +103,8 @@ export function isBlocksError<N extends string>(e: unknown, name: N): e is Error
name: N;
};

export { isSecret }

// @public
export function loadConfigToProcessEnv(): Promise<void>;

Expand Down Expand Up @@ -195,6 +207,16 @@ export type ScopeParent = Scope | {
id: string;
};

export { secret }

export { secretEnvVarName }

export { SecretOptions }

export { secretParameterName }

export { SecretValue }

// @public
export function unlockRouteRegistry(): void;

Expand Down
105 changes: 105 additions & 0 deletions packages/core/src/hosting-secrets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import assert from 'node:assert';
import { describe, it } from 'node:test';
import { secret } from '@aws-blocks/hosting';
import {
collectSynthSecretKeys,
partitionEnvironment,
resolveDomainNames,
resolveSecretsAtSynth,
} from './hosting-secrets.js';

void describe('partitionEnvironment()', () => {
void it('splits plain / runtime-secret / exposeAsEnv', () => {
const { plain, runtimeSecrets, exposeSecrets } = partitionEnvironment({
FLAG: 'on',
STRIPE_KEY: secret('STRIPE_KEY'),
LEGACY: secret('LEGACY', { exposeAsEnv: true }),
});
assert.deepStrictEqual(plain, { FLAG: 'on' });
assert.deepStrictEqual(
runtimeSecrets.map((s) => s.key),
['STRIPE_KEY'],
);
assert.deepStrictEqual(
exposeSecrets.map((s) => s.key),
['LEGACY'],
);
});

void it('rejects env key / secret key mismatch', () => {
assert.throws(
() => partitionEnvironment({ STRIPE_KEY: secret('OTHER') }),
/must match the environment variable name/,
);
});

void it('handles undefined', () => {
const { plain, runtimeSecrets, exposeSecrets } = partitionEnvironment(undefined);
assert.deepStrictEqual(plain, {});
assert.strictEqual(runtimeSecrets.length, 0);
assert.strictEqual(exposeSecrets.length, 0);
});
});

void describe('collectSynthSecretKeys()', () => {
void it('gathers domain + exposeAsEnv keys, deduped', () => {
const keys = collectSynthSecretKeys(
['example.com', secret('DOMAIN_PROD')],
[secret('LEGACY', { exposeAsEnv: true }), secret('DOMAIN_PROD', { exposeAsEnv: true })],
);
assert.deepStrictEqual([...keys].sort(), ['DOMAIN_PROD', 'LEGACY']);
});

void it('returns nothing when no synth-time secrets exist', () => {
assert.deepStrictEqual(collectSynthSecretKeys('example.com', []), []);
assert.deepStrictEqual(collectSynthSecretKeys(undefined, []), []);
});
});

void describe('resolveSecretsAtSynth()', () => {
void it('fetches each referenced key with decryption', async () => {
const seen: string[] = [];
const resolved = await resolveSecretsAtSynth(['DOMAIN_PROD'], async (name) => {
seen.push(name);
return 'prod.example.com';
});
assert.deepStrictEqual(seen, ['/blocks/secrets/DOMAIN_PROD']);
assert.strictEqual(resolved.get('DOMAIN_PROD'), 'prod.example.com');
});

void it('throws an actionable error when a secret is not set', async () => {
await assert.rejects(
resolveSecretsAtSynth(['DOMAIN_PROD'], async () => {
const e = new Error('not found');
e.name = 'ParameterNotFound';
throw e;
}),
/blocks secret set DOMAIN_PROD/,
);
});
});

void describe('resolveDomainNames()', () => {
void it('replaces markers with resolved literals, preserving shape', () => {
const resolved = new Map([['DOMAIN_PROD', 'prod.example.com']]);
assert.strictEqual(resolveDomainNames(secret('DOMAIN_PROD'), resolved), 'prod.example.com');
assert.deepStrictEqual(resolveDomainNames(['www.example.com', secret('DOMAIN_PROD')], resolved), [
'www.example.com',
'prod.example.com',
]);
});

void it('throws if a marker reached the sync path unresolved', () => {
assert.throws(
() => resolveDomainNames(secret('DOMAIN_PROD'), new Map()),
/requires async resolution.*Hosting\.create/s,
);
});

void it('passes literal domains through untouched', () => {
assert.strictEqual(resolveDomainNames('example.com', new Map()), 'example.com');
});
});
Loading
Loading