feat(hosting): add secret() support for self-hosted deployments#115
feat(hosting): add secret() support for self-hosted deployments#115osama-rizk wants to merge 23 commits into
Conversation
24159af to
758abdf
Compare
Adds a secret() reference for sensitive values (API keys, third-party
credentials, custom domains) backed by SSM Parameter Store SecureString,
so they are never hardcoded, committed, or written into the CFN template.
- secret('KEY') marker: dependency-free value object in @aws-blocks/hosting
(re-exported via core/blocks), so @aws-blocks/pipeline can later share it
without a dependency cycle.
- Two resolution strategies, chosen per prop:
* compute.environment -> RUNTIME (secure default): inject the SSM param
NAME as BLOCKS_SECRET_PARAM_<KEY>, grant ssm:GetParameter (scoped) +
kms:Decrypt (kms:ViaService); getSecret('KEY') fetches+decrypts on first
use. Value stays encrypted at rest; never in the template.
* domain.domainName and secret(..., { exposeAsEnv: true }) -> SYNTH TIME
via SDK GetParameter(WithDecryption), inlined as literal. ssm-secure CFN
dynamic refs are not allowed in CloudFront Aliases / Lambda env, so
synth-time SDK resolution is correct; a domain is public anyway. Async,
so use await Hosting.create(scope, id, props).
- getSecret('KEY') runtime resolver: env-first (local dev / exposeAsEnv),
else SSM fetch+decrypt, cached per cold start, concurrent calls coalesced.
- secret CLI (runSecretCli/setSecret/listSecrets/removeSecret):
blocks secret set/list/remove manage SecureStrings at /blocks/secrets/<KEY>
(flat namespace, 1:1 key<->reference). list returns names only. Wired into
the nextjs template as npm run secret.
Path convention centralized in secretParameterName() (/blocks/secrets/<KEY>).
Backward compatible: new Hosting() still works; only synth-time secrets need
Hosting.create(). Unit + CDK-assertion tests cover marker, runtime resolver,
partition/resolution helpers, IAM grants, and no-plaintext-leak.
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.
758abdf to
49a831d
Compare
The secret CLI was only wired into the nextjs template. Add the same
wrapper (aws-blocks/scripts/secret.ts) + npm script ("secret") to every
template with a deploy flow (auth-cognito, backend, bare, default, demo,
react) so a scaffolded app can run:
npm run secret -- set KEY value
npm run secret -- list
npm run secret -- remove KEY
out of the box — consistent with how deploy/sandbox/destroy are already
exposed as package.json scripts. The amplify template is intentionally
skipped (no standalone deploy flow).
Move secret marker resolution into the shared HostingConstruct so ANY consumer (Blocks, standalone CDK, Amplify's defineHosting) gets runtime secrets for free, not just core.Hosting. - HostingConstruct.environment now accepts secret() markers (EnvValue) and a `secrets` prop (prefix/store). The construct partitions env, applies plain vars, wires RUNTIME markers (inject SSM locator + grant read/decrypt), and throws on unresolved synth-time (exposeAsEnv) markers. - Add secret-resolve.ts: the CDK-aware engine (partitionEnvironment, resolveSecretsAtSynth, resolveDomainNames, wireRuntimeSecret) + a synth fetcher override for tests. - Add secret-cli.ts: shared set/list/remove CLI core, parameterized by label/prefix/store so each consumer wraps it with its own namespace. - Pluggable store: SecretStore = 'ssm' | 'secrets-manager'; runtime resolver reads the _STORE hint and fetches from SSM (default) or Secrets Manager. - Fix reserved-prefix bug: DEFAULT_SECRET_PARAMETER_PREFIX '/aws-hosting/secrets' -> '/hosting/secrets'. SSM reserves any path starting with 'aws'/'ssm' and rejects it with "No access to reserved parameter name". Found via live e2e. Tests: hosting 745 pass.
Let a CodeConnections connectionArn be supplied as a secret() marker, resolved at SYNTH time from the store (SSM/Secrets Manager) via the shared hosting resolver. CodeBuild has no .env, so a store-backed secret is the right mechanism for a source credential. - connectionArn: string | SecretValue. - Pipeline.create() resolves a marker connectionArn before validation/use (neutral /hosting/secrets prefix); sync `new Pipeline()` rejects a marker with a "use Pipeline.create()" error. - Re-export secret/isSecret/SecretValue from @aws-blocks/hosting/secret so pipeline users import the marker from one place. - Add pipeline dep on @aws-blocks/hosting (leaf; no cycle). - test-apps/pipeline: pipeline-secret.cdk.ts variant proving synth resolution. Tests: pipeline 84 pass.
…lic AWS guidance) The AWS guidance is now public — "How to choose the right AWS service for managing secrets and configurations" recommends Secrets Manager for application secrets/credentials (API tokens, DB passwords) and Parameter Store for non-sensitive config. secret() holds credentials, so flip the default back: - DEFAULT_SECRET_STORE: 'ssm' -> 'secrets-manager'. One-line change via the established seam; SSM SecureString stays a first-class opt-in (store: 'ssm') for non-sensitive config. secretStoreLocator() + _STORE runtime hint unchanged. - Blocks/core unaffected (own SSM /blocks/secrets namespace, injects no hint). - Tests flipped to assert SM as default + SSM as opt-in; the pure-locator test now asserts against DEFAULT_SECRET_STORE so it's robust to future flips. - Doc comments cite the public guidance blog. hosting 746 pass, pipeline 84 pass. Live-verified end-to-end on Secrets Manager (isolated Function-URL probe running the real wireRuntimeSecret + getSecret): value fingerprint matched, no leak, least-priv secretsmanager:GetSecretValue + kms:Decrypt (ViaService-scoped).
Add an optional `stage` so one logical secret can hold a different value per environment, with a shared fallback — matching SST's --fallback use case (ephemeral PR/preview stages inherit a shared default; prod/beta differ). - secretStoreLocator gains `stage` → <prefix>/<stage>/<key>; omit for the shared <prefix>/<key> (the fallback target). New secretFallbackEnvVarName helper. - SecretResolveOptions.stage. wireRuntimeSecret injects the stage locator + a _FALLBACK shared locator and grants read on BOTH ARNs (deduped when stageless). - Runtime getSecret does a two-try: primary (stage) then _FALLBACK on not-found, caches the winner — so a per-stage value can be added/rotated without redeploy. No _FALLBACK wired ⇒ single fetch, unchanged. - resolveSecretsAtSynth (domains, connectionArn) resolves stage-then-shared eagerly, with an error naming both when neither is set. - CLI: `secret set/list/remove … --stage <name>` (and --stage=<name>); list is stage-scoped when given. Fully opt-in and backward-compatible: omit `stage` and behavior is identical to before (all 746 existing hosting tests unchanged). Core untouched; pipeline 84.
🦋 Changeset detectedLatest commit: 3cf3c2a The changes in this PR will be included in the next version bump. This PR includes changesets to release 25 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Add `buildSecrets: { NAME: secret('NAME') }` to PipelineProps for credentials
the build/deploy commands need (npm publish, docker login, private npm ci,
signing). Distinct from source.connectionArn (consumed by the CodePipeline
service, synth-time): buildSecrets are consumed by the build commands and
fetched by CodeBuild AT BUILD TIME.
- Mapped to the synth CodeBuild project's environmentVariables with type
SECRETS_MANAGER (default) or PARAMETER_STORE (secrets.store: 'ssm'), value =
secretStoreLocator(key, {prefix, store}). CodeBuild grants the build role read
and masks the value in logs; the value never enters the template, so rotating
takes effect next build with no redeploy.
- New `secrets: { prefix?, store? }` prop selects namespace/store (shared with
a secret connectionArn). Non-marker buildSecrets values are rejected.
- Reuses the hosting leaf's secretStoreLocator (no new store code). Optional +
backward-compatible: no buildSecrets ⇒ buildEnvironment untouched.
- Tests: SM default, SSM+custom prefix, non-marker rejection, omitted case.
pipeline 88 pass. Core untouched.
…c fixes
Address review feedback (soberm):
- [major] resolveSourceSecrets ignored props.secrets, so a secret() connectionArn
always resolved from the defaults (/hosting/secrets, Secrets Manager) even when
the caller configured secrets: { store, prefix }. It now threads
{ prefix: props.secrets?.prefix, store: props.secrets?.store } into
resolveSecretsAtSynth, so connectionArn and buildSecrets resolve from the same
namespace/store. Added a test asserting the locator (capturing fetcher, not the
value-returning global bypass).
- [minor] secret-resolve.ts SecretResolveOptions.store JSDoc: default 'ssm' →
{@link DEFAULT_SECRET_STORE} ('secrets-manager').
- [minor] test-apps pipeline comment: connectionArn resolves from Secrets Manager
(hosting/secrets/CONNECTION_ARN), not SSM.
- [nit] core/secret-naming.ts: /aws-hosting/secrets → /hosting/secrets (the value
the reserved-prefix fix corrected).
- [nit] core/hosting-secrets.ts: BLOCKS_SECRET_PARAM_<KEY> → HOSTING_SECRET_PARAM_<KEY>.
pipeline 89 pass. Core changes are comment-only.
…ults to Secrets Manager)
The secret() changes rode in on top of a spaces->tabs reindent (plus import re-sorting and re-wrapping of pre-existing code) that biome's format-on-save applied. CI only runs `biome lint`, not the formatter, so the pipeline package was space-indented on main and none of the reformat was required. Reverted the three files to main's formatting and re-applied ONLY the genuine secrets changes, so the diff now shows the feature, not the reflow: types.ts 717 -> 46 (+45/-1) pipeline-construct 1100 -> 97 (+92/-5) pipeline.test.ts 3949 -> 138 (+138/-0, purely additive) No behavior change; 89 pipeline tests pass.
bobbor
left a comment
There was a problem hiding this comment.
Hey @osama-rizk, really impressive work here 🙌 The PR description alone deserves a shout-out, and the design has a lot of genuinely great calls: the shared secretStoreLocator() helper (love that it was born from a live-caught bug), the branded-symbol marker, the coalescing cache in getSecret(), the fail-fast on unresolved exposeAsEnv, and the CodeBuild-native buildSecrets wiring. Test coverage actually asserts the security-relevant behavior too, not just the happy paths 👏
That said, I dug in pretty deep and found a couple of things I'd consider blocking, plus some smaller ones. Details are in the inline comments; TL;DR:
🔴 Blocking
- Cross-stage IAM over-grant: preview/PR stages get standing IAM read on the shared fallback secret (see
secret-resolve.tscomment) - Duplicate
kms:Decryptstatements per secret: policy bloat that can approach the 10KB inline policy limit
🟠 Worth fixing in this PR
3. Secret values land in argv / shell history via secret set KEY value
4. Default prefix /hosting/secrets is account-global, so two apps in one account collide
5. Plain Error throws in secret-resolve.ts while the rest of hosting uses structured HostingError
6. Pipeline's new dep on "@aws-blocks/hosting": "*" resolves to any version once published
7. Secrets Manager list leaks stage-scoped entries into the shared-namespace listing
8. core's hosting-secrets.ts duplicates the resolve engine as an SSM-only variant with no guard against divergence
🟡 Minor / fast-follow
- No cache TTL in
getSecret(): rotation only lands on cold start, which can be hours with provisioned concurrency sub_path_exports.test.tswasn't updated for the new./secretentry- No README/docs updates despite the new public API and CLI workflow
- Template
secret.tsfiles use 2-space indent whilebiome.jsonmandates tabs; also thehosting/src/index.tsreformat of existing exports inflates the diff a bit - CLI region mismatch: you already called it out as a follow-up, a
--regionflag would be cheap insurance - Worth confirming
API.mdcame from api-extractor and not a hand edit
One thing I checked so nobody else trips on it: requestHandler: { requestTimeout: 5000 } is totally fine 👍 the plain-object short form is officially supported since SDK v3.521.0, so no change needed there.
Overall: really solid feature, and the two blocking items are fixable without touching the architecture. Happy to re-review quickly once they're addressed 🚀
| // fallback), deduped. | ||
| const locators = [...new Set([primary, ...(fallback ? [fallback] : [])])]; | ||
| for (const locator of locators) { | ||
| grantSecretRead(fn, locator, store); |
There was a problem hiding this comment.
🔴 Blocking: cross-stage IAM over-grant on the shared fallback
This loop grants the compute role read on both the stage locator and the shared fallback <prefix>/<key>, unconditionally. The two-try fallback is only enforced in application logic; the IAM permission is standing. That means every ephemeral PR/preview deploy holds IAM read access to the shared fallback secret at all times, and a compromised handler in a preview stage can call GetSecretValue on the fallback ARN directly, bypassing the two-try logic.
The docs example (sk_test_shared as fallback) is safe, but nothing stops or warns the inverse (someone putting a prod value in the shared slot and letting previews "inherit" it).
Suggestions: make the fallback grant opt-in per stage, or document loudly that the shared fallback must never hold production values (safe dev defaults only).
| } | ||
|
|
||
| /** Grant a Lambda role least-priv read+decrypt on one secret locator. */ | ||
| function grantSecretRead(fn: cdk.aws_lambda.Function, locator: string, store: SecretStore): void { |
There was a problem hiding this comment.
🔴 Blocking: duplicate kms:Decrypt statements per secret
grantSecretRead is called once per locator, and each call appends its own kms:Decrypt statement (Resource: '*' + kms:ViaService condition). CDK doesn't deduplicate addToRolePolicy, so N secrets with stage fallbacks means up to 2N identical kms:Decrypt statements. Beyond the noise, this bloats the inline policy toward the 10KB limit for apps with several secrets.
Suggestion: hoist the KMS grant out of the per-locator path and add it once per store type after the read grants.
(The Resource: '*' + ViaService pattern itself is fine 👍 that's the documented approach for service-default keys and matches the existing AppSetting code.)
| switch (subcommand) { | ||
| case 'set': { | ||
| const [key, ...valueParts] = rest; | ||
| const value = valueParts.join(' '); |
There was a problem hiding this comment.
🟠 Secret values land in argv and shell history
secret set STRIPE_KEY sk_live_… puts the value into process.argv, which makes it visible in ps output, /proc/*/cmdline, and shell history files. For a tool whose whole pitch is "secrets never end up in git", this is a bit ironic 😅 and it's exactly the kind of footgun the target audience (external devs) will hit.
Suggestion: support --value-stdin or an interactive prompt, or at minimum document the exposure prominently in the CLI help.
| * those prefixes and rejects create/read with "No access to reserved parameter | ||
| * name". (An earlier `/aws-hosting/...` default hit exactly that.) | ||
| */ | ||
| export const DEFAULT_SECRET_PARAMETER_PREFIX = '/hosting/secrets'; |
There was a problem hiding this comment.
🟠 Default prefix is account-global
Two standalone apps in the same account using this default share one namespace: same key name means literally the same secret, and secret list enumerates entries across apps. That's a realistic scenario for the target audience (one personal account, several apps).
Suggestion: derive the default from the app/stack name, or document clearly that multi-app accounts must set a unique prefix.
| for (const [key, value] of Object.entries(environment ?? {})) { | ||
| if (isSecret(value)) { | ||
| if (value.key !== key) { | ||
| throw new Error( |
There was a problem hiding this comment.
🟠 Convention: plain Error vs HostingError
hosting_construct.ts uses structured HostingError ({code, message, resolution}) exclusively, 14 instances and zero plain throws, but the throws in this file (partitionEnvironment, resolveSecretsAtSynth, resolveDomainNames) are plain new Error(...). The exposeSecrets path in the construct got it right with HostingError('UnresolvedSecretError', …). Consumers lose the error-code contract for these paths.
| "dev": "tsc --build --watch" | ||
| }, | ||
| "dependencies": { | ||
| "@aws-blocks/hosting": "*" |
There was a problem hiding this comment.
🟠 Bare "*" dependency on a published package
Pipeline previously had no runtime deps. In the workspace this resolves locally, but in the published package "*" accepts any hosting version whatsoever, which will break consumers on the first incompatible hosting release. Suggest pinning to a proper semver range.
| let nextToken: string | undefined; | ||
| do { | ||
| const result = await client.send( | ||
| new ListSecretsCommand({ |
There was a problem hiding this comment.
🟠 SM list leaks stage-scoped entries into the shared listing
The SSM path uses Recursive: false to keep the listing flat, but this Secrets Manager name filter is a plain prefix match with no depth control, so stage-scoped secrets show up in the shared-namespace listing as prod/KEY etc.
Suggestion: after slicing the prefix, filter out names containing / (or group them by stage, which would actually be a nice UX).
| * Inject the SSM parameter NAME (not the value) for a runtime secret and grant | ||
| * the compute role read+decrypt access scoped to that one parameter. | ||
| */ | ||
| export function wireRuntimeSecret(fn: cdk.aws_lambda.Function, key: string): void { |
There was a problem hiding this comment.
🟠 SSM-only duplicate of the hosting resolve engine
This is a reimplementation of hosting/src/secret-resolve.ts's engine, hardcoded to SSM (no _STORE hint, no stage/fallback, SSM-only IAM). That's consistent today because core pins the Blocks SSM namespace, but there's no comment or assertion saying "SSM-only by design", so the two engines can silently diverge (e.g. if someone later threads a store option through core, they'd get SSM grants with a Secrets Manager runtime read → AccessDenied).
Suggestion: a one-line comment/assertion making the SSM-only contract explicit, or share the engine.
| } | ||
|
|
||
| /** Cache of resolved secret values, keyed by logical secret name. */ | ||
| const cache = new Map<string, string>(); |
There was a problem hiding this comment.
🟡 No TTL: rotation only lands on cold start
The docs say rotated values are picked up "on the next cold start", which is accurate, but with provisioned concurrency or steady traffic a warm sandbox can serve a stale value for hours. Not blocking, but a configurable TTL (even a generous default like 5-15 min) or a prominent note in the rotation docs would save someone a confusing incident.
| "types": "./dist/hosting_error.d.ts", | ||
| "default": "./dist/hosting_error.js" | ||
| }, | ||
| "./secret": { |
There was a problem hiding this comment.
🟡 Missing subpath export test
sub_path_exports.test.ts covers ./constructs, ./adapters, and ./error but wasn't updated for this new ./secret entry. Cheap test, catches broken published artifacts.
What & why
Adds
secret()support to hosting and pipeline — a way to reference sensitive values (API keys, third-party credentials, source connection ARNs, custom domains) without hardcoding them in source, committing them to git, or leaking them into the CloudFormation template.Opt-in and non-blocking — apps not using
secret()are unaffected, and apps using native.env/ third-party stores keep working.Target consumers
One shared engine serves the ways an SSR app reaches aws-blocks hosting, with no coupling between them:
secret()gives it@aws-blocks/core'sHostingblock (@aws-blocks/blocks/cdk)secret()inenvironment/domain+getSecret()in SSR +npm run secretCLI (/blocks/secrets/<KEY>). Can also keep secrets in a backendAppSetting— both work.@aws-blocks/hosting(the L3 construct directly, no Blocks backend)AppSetting:HostingConstructwires runtime markers;getSecret()+npm run secretwith the app's own prefix.The key enabler: runtime marker resolution lives in the shared
HostingConstruct(not justcore.Hosting), so a standalone app that uses the construct directly gets runtime secrets with zero extra code.How it works
secret('STRIPE_KEY')returns a dependency-free marker (a "claim ticket"), not the value. The value lives encrypted in the store (Secrets Manager by default, per AWS's guidance for application credentials; SSM SecureString viastore: 'ssm'for non-sensitive config) and is set out-of-band via the CLI. Markers resolve with one of two strategies, chosen per prop:compute.environmentsecretsmanager:GetSecretValueorssm:GetParameter) +kms:Decrypt, resolve viagetSecret()on first usedomain.domainName,secret(…, { exposeAsEnv: true })ssm-secureCFN dynamic refs aren't allowed in CloudFront Aliases / Lambda env (allowlist-restricted); a domain is public anywaypipelinesource.connectionArnpipelinebuildSecretsSECRETS_MANAGER/PARAMETER_STOREenv varsstore.secretsmanager:GetSecretValueorssm:GetParameter) on that one resource's ARN +kms:Decryptconditioned onkms:ViaService.listoutput.Usage
End-to-end, there are three steps: declare the reference in infra, set the value out-of-band once, retrieve it where it's used.
1. Declare — reference the secret in infra
A standalone app (no Blocks backend) uses the construct directly:
2. Set — write the value out-of-band (never in source)
Choosing the store (default is Secrets Manager; opt into SSM for non-sensitive config): the CLI wrapper passes
store: 'ssm'. The CLI and the deploy must agree on the store and prefix so the value is written and read under the same locator.3. Retrieve — read it at request time
For an
exposeAsEnvsecret,process.env.SENTRY_DSNworks directly (no runtime call) — at the cost of the value being inlined in the Lambda config.Per-stage values with shared fallback (optional)
Give one key a different value per environment, falling back to a shared default:
Resolution tries
<prefix>/<stage>/<key>and falls back to the shared<prefix>/<key>when the stage has none — soprod/betadiffer while ephemeral PR/preview stages inherit the shared default. For runtime secrets the fallback is a two-try at request time, so a per-stage value can be added or rotated without a redeploy. Omitstageentirely for a flat single namespace (unchanged behavior).Pipeline secrets
A pipeline uses
secret()in two places, for its two different consumers:Set both with the same CLI (
secret set CONNECTION_ARN …,secret set NPM_TOKEN …).connectionArnis synth-time (the service needs a literal ARN in the template);buildSecretsare build-time (CodeBuild fetches them per build, so they rotate without a redeploy).Rotation
Re-run
secret setwith the new value. Runtime hosting secrets and pipelinebuildSecretspick it up on the next cold start / next build — no redeploy. Synth-time secrets (exposeAsEnv, domain,connectionArn) are baked in at deploy, so those require a redeploy to re-inline.Tests
hosting: marker creation/validation, path/env naming, the resolve engine (partition / resolve-synth / wire-runtime, per-stage + fallback), the CLI core (incl.--stage),getSecret()runtime resolver (env-first, fetch+decrypt, cache, concurrent coalescing, store hint, two-try fallback), and construct-level assertions (locator injected, value absent,exposeAsEnvrejected pre-resolution, IAM scoped).pipeline:connectionArnmarker resolved at synth + sync-path rejection.core: unchanged (marker stripping +HostingCDK-assertion tests).End-to-end verification (live deploy)
Each consumer was deployed to a disposable sandbox account and probed at a route that calls
getSecret('DEMO_SECRET')in the SSR Lambda. The probe returns only a masked fingerprint (value length + a truncated hash) — never the value — so resolution is proven without exposing the secret.Verified on each deployed Lambda: the env var carries the store locator (not the value), the raw value is absent from the Lambda env and the CloudFormation template, the compute role's read grant is scoped to that one secret's ARN, and
kms:Decryptis conditioned onkms:ViaService. Both stores were exercised live — the SSM path and the Secrets Manager default each resolved end-to-end with the correct fingerprint and least-privilege IAM. TheexposeAsEnvpath correctly inlines a plaintext env var (the documented escape hatch). (Live account IDs, distribution hostnames, and secret material are intentionally omitted here.)Bug caught by the live run (and fixed): the neutral default prefix
/aws-hosting/secretsis rejected by AWS — SSM reserves any parameter path starting withaws/ssm. Changed to/hosting/secrets. A second issue on the Secrets Manager path — the created secret name and the IAM ARN the grant scoped to disagreed on the leading slash — is fixed by a singlesecretStoreLocator()helper shared by the CLI, the grant, and the runtime read.Follow-ups (not in this PR)
defineSecrets({...})builder (pure TS inference, no codegen) could make a typo in a key a compile error rather than a runtime not-found.