Skip to content

fix(hosting): never serve stale placeholder config.json after deploy#174

Open
fossamagna wants to merge 6 commits into
aws-devtools-labs:mainfrom
fossamagna:fix/stale-placeholder-config-json
Open

fix(hosting): never serve stale placeholder config.json after deploy#174
fossamagna wants to merge 6 commits into
aws-devtools-labs:mainfrom
fossamagna:fix/stale-placeholder-config-json

Conversation

@fossamagna

Copy link
Copy Markdown
Contributor

Problem

Issue #, if available: #173

After a normal deploy, a fraction of browsers permanently receive the build-time placeholder /.blocks-sandbox/config.json ({"_placeholder":true}) from CloudFront instead of the real {"apiUrl":…}. The @aws-blocks/core/client getApiUrl() treats a body without apiUrl as invalid and throws, so every client API call (including authApi.getAuthState()) rejects — the login page is rendered permanently unusable for affected users.

Two independent defects combine:

  1. Placeholder gets a 1-year edge cache. The build-time placeholder config.json is written into the static asset dir but is not registered as a no-cache path, so it falls into the mutable asset tier and is uploaded with public, s-maxage=31536000, max-age=0, must-revalidate. Any edge that caches the placeholder during the deploy window keeps serving it for up to a year.
  2. The post-deploy invalidation never matches the real cache key. The skew-protection viewer-request CloudFront function rewrites the URI to /builds/<buildId>/.blocks-sandbox/config.json before the cache lookup, so the real cache key lives under /builds/…. The BucketDeployment invalidated only /.blocks-sandbox/*, which never matches the rewritten key — so the invalidation is a no-op for config.json.

Changes

  • Primary — self-healing placeholder. Register .blocks-sandbox/config.json in manifest.staticAssets.noCachePaths so the placeholder is uploaded with no-cache, no-store, must-revalidate instead of the 1-year mutable cache-control. The edge no longer caches the placeholder long-term. The real config is still deployed separately with its own short max-age=60, so runtime performance is unchanged. (This reuses the existing noCachePaths mechanism, whose own doc names .blocks-sandbox/config.json as the intended use case.)
  • Defense-in-depth — invalidate the real key. Set the config BucketDeployment's distributionPaths to also include the post-rewrite key /builds/<buildId>/.blocks-sandbox/*, so a post-deploy invalidation actually clears any edge entry at its real cache key.

Validation

  • Added two unit tests in packages/core/src/hosting.test.ts (config.json stale-placeholder guard (#173)):
    • asserts the placeholder config.json is uploaded with no-cache, no-store, must-revalidate and that no deployment applies s-maxage=31536000 to it;
    • asserts distributionPaths invalidates the post-rewrite cache key /builds/<id>/.blocks-sandbox/*.
  • Both tests were confirmed to fail before the fix and pass after (TDD).
  • Full @aws-blocks/core suite green (582 tests); no new lint findings on the changed lines.

Checklist

  • PR description included
  • Tests are changed or added
  • Relevant documentation is changed or added (and PR referenced) — N/A (internal behavior fix, no public API/doc change)

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

…s-devtools-labs#173)

The build-time placeholder config.json (`{"_placeholder":true}`) was
uploaded with the 1-year mutable cache-control
(`public, s-maxage=31536000, max-age=0, must-revalidate`), and the
post-deploy CloudFront invalidation targeted `/.blocks-sandbox/*`. The
skew-protection viewer-request function rewrites the URI to
`/builds/<buildId>/.blocks-sandbox/config.json` before the cache lookup,
so the real cache key lives under `/builds/…` and the invalidation never
matches it. An edge that cached the placeholder during the deploy window
could keep serving it for up to a year — `getApiUrl()` then throws and
every client API call fails.

- Primary: register `.blocks-sandbox/config.json` as a no-cache path so
  the placeholder is uploaded with `no-cache, no-store, must-revalidate`
  and edges never cache it long-term. The real config is still deployed
  separately with its own short `max-age=60`.
- Defense-in-depth: also invalidate the post-rewrite cache key
  `/builds/<buildId>/.blocks-sandbox/*` so a post-deploy invalidation
  actually clears any edge entry at its real key.
@fossamagna
fossamagna requested a review from a team as a code owner July 9, 2026 05:39
@fossamagna fossamagna changed the title fix(core): never serve stale placeholder config.json after deploy (#173) fix(core): never serve stale placeholder config.json after deploy Jul 9, 2026
@fossamagna fossamagna changed the title fix(core): never serve stale placeholder config.json after deploy fix(hosting): never serve stale placeholder config.json after deploy Jul 9, 2026
ahmedhamouda78

This comment was marked as duplicate.

@ahmedhamouda78
ahmedhamouda78 dismissed their stale review July 10, 2026 18:56

Superseded by re-post with inline comments

ahmedhamouda78

This comment was marked as duplicate.

@ahmedhamouda78
ahmedhamouda78 dismissed their stale review July 10, 2026 18:57

Superseded — diagnosing line anchoring

ahmedhamouda78

This comment was marked as duplicate.

ahmedhamouda78
ahmedhamouda78 previously approved these changes Jul 10, 2026

@ahmedhamouda78 ahmedhamouda78 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Correctly fixes both root-cause defects behind #173: (1) the placeholder config.json fell into the mutable-asset tier (public, s-maxage=31536000) and could be edge-cached for up to a year — now registered in noCachePaths and uploaded with no-cache, no-store, must-revalidate; and (2) the post-deploy invalidation targeted /.blocks-sandbox/* but the skew-protection CloudFront function rewrites the URI to /builds//.blocks-sandbox/config.json before cache lookup, so the invalidation never matched — now adds /builds/${buildId}/.blocks-sandbox/* while correctly retaining /.blocks-sandbox/* for non-rewrite cases. Scoping invalidation to the current buildId only is the right call (cookie-pinned users keep their config). Clean TDD, no security concerns, zero drift from main.

Non-blocking suggestions below.

// short `max-age=60`.
const configNoCachePath = `${BLOCKS_SANDBOX_DIR}/config.json`;
const existingNoCachePaths = manifest.staticAssets.noCachePaths ?? [];
manifest.staticAssets.noCachePaths = existingNoCachePaths.includes(configNoCachePath)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The noCachePaths registration runs unconditionally (no api guard), which is correct — but both new tests always pass MOCK_API. A static-only test (no api prop) would guard against a future regression where the registration is accidentally moved inside the if (props.api) block, since the placeholder is always written.

Comment thread packages/core/src/hosting.test.ts Outdated
const deployments = bucketDeployments(stack);
// The BlocksConfigDeployment is the one targeting the .blocks-sandbox
// key prefix with an invalidation configured.
const configDeploy = deployments.find(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The configDeploy lookup uses a structural heuristic (DestinationBucketKeyPrefix.endsWith('.blocks-sandbox') && Array.isArray(DistributionPaths)). If CDK renames internal properties the heuristic silently fails to find the deployment and the test throws on assert.ok(undefined, ...) rather than a clear failure. Prefer Template.fromStack + Match.objectLike for CDK-upgrade-resilient assertions. (The .blocks-sandbox regex escaping is correct.)

JSON.stringify({ _placeholder: true }),
);

// ── 5a. Mark config.json as a no-cache path ─────────────────────

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the 8-line comment narrates the full #173 bug history that's already in the commit message. Keep the non-obvious invariant inline (config.json is mutable and must not inherit the content-hashed-asset s-maxage) and move the forensics to the commit body.

Comment thread packages/core/src/hosting.test.ts Outdated
});
});

// ── #173: stale placeholder config.json never served long-term ──

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the describe(...) block name already conveys this; section-divider comments inside test bodies add noise.

- Add a static-only (no `api`) test asserting the placeholder is
  registered as a no-cache path — guards against a regression that
  makes the registration depend on `props.api`.
- Rewrite the invalidation-path test to assert via
  `Template.hasResourceProperties` + `Match` so a future CDK property
  rename fails loudly instead of silently skipping the assertion.
- Trim the step-5a comment to the non-obvious invariant (config.json is
  mutable and must not inherit the content-hashed-asset s-maxage); the
  bug forensics already live in the fix commit body.
- Drop a redundant section-divider comment inside the test file.
@fossamagna

fossamagna commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

@ahmedhamouda78
Thanks for the review! All four suggestions are addressed in a695afb. (Posting here as a top-level summary because the inline threads landed on a dismissed review and are collapsed/outdated in the UI.)

  • Static-only test (hosting.ts noCachePaths, no api guard): Added registers the placeholder as a no-cache path even for a static-only site (no api) — builds a Hosting with no api prop and asserts the placeholder is uploaded with no-cache, no-store, must-revalidate. Since the placeholder is always written, this guards against a regression that moves the registration inside an if (props.api) block.
  • CDK-resilient assertion (test:1587): Rewrote the invalidation-path test to assert via Template.fromStack(stack).hasResourceProperties('Custom::CDKBucketDeployment', Match.objectLike({ DistributionPaths: Match.arrayWith([Match.stringLikeRegexp('^/builds/.+/\\.blocks-sandbox/\\*$')]) })), so a future CDK property rename fails loudly at the assertion instead of silently skipping a structural-heuristic lookup.
  • Trim step-5a comment (hosting.ts:503): Reduced to the non-obvious invariant (config.json is mutable and must not inherit the content-hashed-asset s-maxage); the bug forensics stay in the fix commit body.
  • Drop section-divider comment (test:1511): Removed — the describe(...) block name already conveys it.

Full @aws-blocks/core suite is green (583 tests) and there are no new lint findings on the changed lines.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants