Skip to content

Commit a8de811

Browse files
fix(dashboard,marketing): T15+T18 wave-3 residual fixes (count drift, tier source, 429 hint, legacy double-nav, /for-agents, tool count, deploy timing) (#109)
Residual fixes carried over from PR #104 (fix/bugbash-2026-05-20-wave3), which conflicted with current main after sibling PRs #105 (llms.txt sync) and proposed-PR #107 (MarketingNav shared module) landed. PR #104 closed in favor of this rebased, smaller cherry-pick of the residual unique fixes; the nav-drift fix (T18 P1-1) lives in #107. | Finding | Fix | |---|---| | **P2-1** Overview tile counted all-envs (`resources.length`) while sidebar badge counted env-scoped (`ctx.counts.resources`) → users saw "8" on tile, "5" in rail | OverviewPage derives `envScopedResources` from the same `r.env ?? 'production' === ctx.env` filter the sidebar uses; tile + "View all N" link + newThisWeek delta all read from that single source | | **P2-3** `DeploymentsPage` read `ctx.me?.user.tier`; every other page reads `team.tier`. Latent drift — the moment the API splits per-user vs per-team, this gate breaks silently | Switched to `team.tier` (the team IS the billing entity). Regression test pins it. | | **P2-7** DeploymentsPage swallowed every fetch error to "No deployments yet" — a rate-limited 429 looked like normal empty state | Wired through `retryHint` (shared with TeamPage); renders a `[data-testid=deployments-error]` banner above the table, honors `Retry-After` | | **P2-2** Row links on ResourcesPage, DeploymentsPage, and TeamPage's billing button targeted unprefixed legacy paths → render→Navigate→render double-hop on every click | All internal links now go straight to `/app/*`. Legacy unprefixed paths remain for external/bookmarked links only. Playwright spec walks the live row. | | Finding | Fix | |---|---| | **P1-2** Homepage 'For agents' was `#for-agents` (same-page anchor); PublicShell linked to `/for-agents`. Same label, two destinations | `ROUTES.forAgents` now points at `/for-agents`; the page's own `id="for-agents"` section still scrolls for users already on the homepage | | **P1-4** Headline says "Seven services. One bundle." but the MCP card says "Six tools registered" (dropped webhook) | MCP card now says "Seven tools registered" listing webhook | | **P1-6** Homepage Deploy `liveIn: '<10s'` contradicts content/llms.txt (~30–90s) and content/docs/deploy.md (~30s build) by 3–9× | Switched to `'~60s'` — matches docs and llms.txt | | Wave-3 finding | Where it lives | |---|---| | T18 P1-1 nav-shell drift (PublicShell vs MarketingPage inline nav) | PR #107 (MarketingNav shared module) | | T18 P0-1/P0-2 stale `public/llms.txt` + `public/llms-full.txt` | PR #105 (already on main — `scripts/fetch-content.mjs` syncs from `.content/`) | | `publicNav.ts` new module | DROPPED — PR #107's `MarketingNav.tsx` already supplies the shared constant | ``` Symptom: resource count drift (T15 P2-1) Enumeration: rg -nF 'resources.length' src/pages/OverviewPage.tsx Sites found: 2 (Stat tile + "View all N" link) Sites touched: 2 (both → envScopedCount) Coverage test: OverviewPage.test.tsx "resources stat tile counts only resources in the current env (matches sidebar)" Symptom: user.tier vs team.tier drift (T15 P2-3) Enumeration: rg -n 'ctx\.me\?\.user\.tier|ctx\.me\?\.team\.tier' src/ Sites found: 1 user.tier (DeploymentsPage); team.tier everywhere else Sites touched: 1 (DeploymentsPage → team.tier) Coverage test: DeploymentsPage.test.tsx "uses team.tier (not user.tier) for the private-deploy gate" Symptom: legacy-redirect double-nav (T15 P2-2) Enumeration: rg -nE 'to=\{?"/(resources|deployments|billing|vault|team|settings)/' src/pages Sites found: 3 (ResourcesPage row, DeploymentsPage row, TeamPage billing link) Sites touched: 3 (all → /app/*) Coverage tests: DeploymentsPage.test.tsx (positive + negative href assertions), navigation.spec.ts (Playwright: row href starts /app/resources/) Symptom: 429 swallowed → "No deployments yet" lie (T15 P2-7) Enumeration: rg -nE 'isRateLimited|retryAfterSeconds|formatRetryHint' src/pages Sites found: was 1 (TeamPage only), now 2 (TeamPage + DeploymentsPage) Coverage tests: DeploymentsPage.test.tsx "surfaces a real error banner (not 'No deployments yet')" "renders a 429 rate-limit hint with the Retry-After seconds" Symptom: 'For agents' anchor vs route drift (T18 P1-2) Enumeration: rg -nE 'forAgents|for-agents' src/pages/MarketingPage.tsx Sites found: 1 (ROUTES.forAgents) Sites touched: 1 (#for-agents → /for-agents) Coverage test: MarketingPage.test.tsx "homepage nav 'For agents' link points at /for-agents (not '#for-agents')" Symptom: service-count contradiction (T18 P1-4) Enumeration: rg -nE 'Seven services|Six tools|Seven tools' src/pages/MarketingPage.tsx Sites found: 2 (headline + MCP card) Sites touched: 1 (MCP card → "Seven tools registered" including webhook) Coverage test: MarketingPage.test.tsx "'Seven services' headline matches the MCP tools card" Symptom: homepage deploy timing lie (T18 P1-6) Enumeration: rg -nE 'liveIn|<10s|~60s' src/pages/MarketingPage.tsx Sites found: 1 (SERVICES.dp.liveIn) Sites touched: 1 ('<10s' → '~60s', matches content/docs/deploy.md) Coverage test: MarketingPage.test.tsx "Deploy service card claims a build window consistent with content/llms.txt" Live verified: pending — opens this PR; do not auto-merge. ``` ``` npx tsc --noEmit → clean npm run build → built in 3.15s + 120 prerender files npx vitest run → 668 pass / 3 skip / 0 fail (was 662 pass; +6 new regression tests) ``` Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 43cb900 commit a8de811

9 files changed

Lines changed: 305 additions & 31 deletions

e2e/navigation.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,21 @@ test.describe('Navigation', () => {
4040
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible()
4141
}
4242
})
43+
44+
// T15 P2-2 regression guard: clicking a resource row on /app/resources
45+
// must land directly on /app/resources/<token> — no intermediate
46+
// /resources/<token> hop through LegacyResourceRedirect. The previous
47+
// implementation linked rows at the unprefixed legacy path, which
48+
// resolved via <Navigate replace> → render→navigate→render, adding
49+
// a wrong intermediate entry to the browser history.
50+
test('resource row links go straight to /app/resources/:id (no legacy hop)', async ({ page }) => {
51+
await page.goto('/app/resources')
52+
// The first row's `<a class="res-name">` wraps the resource name +
53+
// identifiers. Its href must already be the /app/-prefixed path.
54+
const firstRow = page.getByRole('link').filter({ hasText: /flashcards-db/i }).first()
55+
await expect(firstRow).toBeVisible()
56+
const href = await firstRow.getAttribute('href')
57+
expect(href).toMatch(/^\/app\/resources\//)
58+
expect(href).not.toMatch(/^\/resources\//)
59+
})
4360
})

src/pages/DeploymentsPage.test.tsx

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,31 @@ describe('DeploymentsPage — empty state', () => {
104104
expect(text).toMatch(/\/deploy\/new/)
105105
})
106106

107-
it('renders the same honest empty state when listDeployments rejects', async () => {
108-
// The page swallows errors and falls back to the empty state — better
109-
// than rendering a fabricated row. The contract test in index.test.ts
110-
// asserts the API helper *does* propagate so a future surface could
111-
// render a real error banner; this surface chooses empty for now.
107+
it('surfaces a real error banner (not "No deployments yet") when listDeployments rejects', async () => {
108+
// T15 P2-7 fix: a 429 or 5xx must NOT collapse to "No deployments
109+
// yet" — that lies about platform state and looks like normal empty
110+
// state. The page renders a dedicated error banner via retryHint and
111+
// an honest empty list under it. Regression guard: catch any future
112+
// refactor that re-swallows the error.
112113
mockListDeployments.mockRejectedValueOnce(new Error('network'))
113114
render(withRouter(<DeploymentsPage />))
114-
const empty = await screen.findByTestId('deployments-empty')
115-
expect(empty.textContent).toMatch(/No deployments yet/)
115+
const banner = await screen.findByTestId('deployments-error')
116+
expect(banner.textContent).toMatch(/Could not load deployments/)
117+
})
118+
119+
it('renders a 429 rate-limit hint with the Retry-After seconds', async () => {
120+
// Regression guard for T15 P2-7: DeploymentsPage now consumes
121+
// retryHint just like TeamPage. A rejected fetch carrying status=429
122+
// + retryAfter must render the user-friendly retry hint, not a raw
123+
// error string.
124+
const rateLimited: Error & { status?: number; retryAfter?: number } = new Error('rate limited')
125+
rateLimited.status = 429
126+
rateLimited.retryAfter = 30
127+
mockListDeployments.mockRejectedValueOnce(rateLimited)
128+
render(withRouter(<DeploymentsPage />))
129+
const banner = await screen.findByTestId('deployments-error')
130+
expect(banner.textContent).toMatch(/Too many requests/i)
131+
expect(banner.textContent).toMatch(/30 seconds/i)
116132
})
117133
})
118134

@@ -135,14 +151,22 @@ describe('DeploymentsPage — non-empty state', () => {
135151
expect(container.querySelector('[data-testid="deployments-empty"]')).toBeNull(),
136152
)
137153

138-
// Each row is a <Link to="/deployments/:app_id"> — we route by app_id
139-
// (not the UUID `id`) because GET /api/v1/deployments/:id on the
140-
// agent API resolves `:id` against the app_id column. Routing by
141-
// UUID would 404.
142-
const links = Array.from(container.querySelectorAll('a[href^="/deployments/"]'))
154+
// Each row is a <Link to="/app/deployments/:app_id"> — we route by
155+
// app_id (not the UUID `id`) because GET /api/v1/deployments/:id on
156+
// the agent API resolves `:id` against the app_id column. Routing
157+
// by UUID would 404. Linking to /app/deployments/* directly (rather
158+
// than the unprefixed legacy path) avoids the
159+
// LegacyDeploymentRedirect render→Navigate→render double-hop on
160+
// every row click — regression guard for T15 P2-2.
161+
const links = Array.from(container.querySelectorAll('a[href^="/app/deployments/"]'))
143162
expect(links.length).toBe(2)
144-
expect(links.map((a) => a.getAttribute('href'))).toContain('/deployments/app-a')
145-
expect(links.map((a) => a.getAttribute('href'))).toContain('/deployments/app-b')
163+
expect(links.map((a) => a.getAttribute('href'))).toContain('/app/deployments/app-a')
164+
expect(links.map((a) => a.getAttribute('href'))).toContain('/app/deployments/app-b')
165+
// Negative assertion: no unprefixed /deployments/* links. The legacy
166+
// unprefixed route exists for external bookmarks only; internal nav
167+
// must skip the redirect.
168+
const legacyLinks = Array.from(container.querySelectorAll('a[href^="/deployments/"]'))
169+
expect(legacyLinks.length).toBe(0)
146170

147171
// URL column renders the hostname (https:// stripped). Use textContent
148172
// on the full page rather than scoping to row — the row uses CSS grid
@@ -229,4 +253,23 @@ describe('DeploymentsPage — private deploy section, tier-gated', () => {
229253
expect(toggle.checked).toBe(false)
230254
expect(screen.queryByTestId('ip-allow-list')).toBeNull()
231255
})
256+
257+
// T15 P2-3 regression guard: DeploymentsPage must read the
258+
// private-deploy tier gate from team.tier, NOT user.tier. The team is
259+
// the billing entity; reading from user.tier was a latent divergence
260+
// that would silently break the gate the moment the API splits the
261+
// two fields. Construct a `me` shape where team.tier='pro' but
262+
// user.tier='hobby' — under the bug, the page would render the
263+
// hobby upsell; under the fix it must render the pro configurator.
264+
it('uses team.tier (not user.tier) for the private-deploy gate', async () => {
265+
mockMe = {
266+
user: { id: 'u', email: 'me@test', tier: 'hobby', team_id: 't', created_at: '' },
267+
team: { id: 't', slug: 't', name: 't', owner_id: 'u', member_count: 1, tier: 'pro', created_at: '' },
268+
}
269+
render(withRouter(<DeploymentsPage />))
270+
await waitFor(() => screen.getByTestId('private-deploy-section'))
271+
// team.tier=pro → configurator renders, upsell does NOT.
272+
expect(screen.getByTestId('private-deploy-configurator')).toBeTruthy()
273+
expect(screen.queryByTestId('private-deploy-upsell')).toBeNull()
274+
})
232275
})

src/pages/DeploymentsPage.tsx

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { TtlBadge } from '../components/TtlBadge'
1010
import * as api from '../api'
1111
import type { DashboardDeployment, Tier } from '../api'
1212
import { useDashboardCtx } from '../hooks/useDashboardCtx'
13+
import { isRateLimited, retryAfterSeconds, formatRetryHint } from '../lib/retryHint'
1314

1415
// B7-P2 (2026-05-20): status filter + sort chips. The sidebar env switch
1516
// already filters by env on the API side, but a Pro user with 10 deploys
@@ -35,13 +36,27 @@ function statusBucket(s: DashboardDeployment['status']): StatusFilter {
3536
// will reject.
3637
const PRIVATE_DEPLOY_TIERS: ReadonlySet<Tier> = new Set(['pro', 'team', 'growth'])
3738

39+
// LoadError mirrors TeamPage's shape — the 429 retry-hint pattern is
40+
// shared. A rate-limited or 5xx fetch must NOT collapse to "No deployments
41+
// yet" (which mis-reports the platform state and erodes trust); we surface
42+
// a real error banner instead and honor any Retry-After hint.
43+
type LoadError = { message: string; rateLimited: boolean; retrySeconds: number | null }
44+
3845
export function DeploymentsPage() {
3946
const ctx = useDashboardCtx()
4047
const [items, setItems] = useState<DashboardDeployment[]>([])
4148
const [loading, setLoading] = useState(true)
4249
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
4350
const [sort, setSort] = useState<SortKey>('recent')
44-
const tier = (ctx.me?.user.tier ?? 'anonymous') as Tier
51+
const [err, setErr] = useState<LoadError | null>(null)
52+
// T15 P2-3: Tier is a TEAM attribute (billing entity is the team, not
53+
// the individual user). All other pages — BillingPage, ResourcesPage,
54+
// VaultPage, OverviewPage, TeamPage, AppShell — read ctx.me?.team.tier.
55+
// Reading ctx.me?.user.tier here was a latent divergence: today
56+
// fetchMe() populates both fields from the same `me.tier`, so they
57+
// agree, but the moment the API splits per-user vs per-team tier the
58+
// private-deploy gate on this one page silently drifts.
59+
const tier = (ctx.me?.team?.tier ?? 'anonymous') as Tier
4560
const canUsePrivateDeploy = PRIVATE_DEPLOY_TIERS.has(tier)
4661

4762
// Derived view: env is server-filtered (?env= query, see useEffect dep);
@@ -75,20 +90,27 @@ export function DeploymentsPage() {
7590
// query param; switching envs triggers a refetch via the dep array.
7691
useEffect(() => {
7792
let cancelled = false
93+
setErr(null)
94+
setLoading(true)
7895
api
7996
.listDeployments(ctx.env)
8097
.then((r) => {
8198
if (cancelled) return
8299
setItems(r.items)
83100
setLoading(false)
84101
})
85-
.catch(() => {
86-
// Honest empty state on failure — the page renders the no-
87-
// deployments hint rather than fabricating placeholder rows.
88-
if (!cancelled) {
89-
setItems([])
90-
setLoading(false)
91-
}
102+
.catch((e) => {
103+
if (cancelled) return
104+
// T15 P2-7: a 429 or 5xx must NOT silently collapse the list to
105+
// "No deployments yet" — that lies about platform state. Surface
106+
// the error banner instead (rate-limit aware via retryHint).
107+
setItems([])
108+
setErr({
109+
message: e?.message ?? 'Could not load deployments',
110+
rateLimited: isRateLimited(e),
111+
retrySeconds: retryAfterSeconds(e),
112+
})
113+
setLoading(false)
92114
})
93115
return () => {
94116
cancelled = true
@@ -102,6 +124,35 @@ export function DeploymentsPage() {
102124
for paid-tier consideration. */}
103125
<QuotaWallBanner teamId={ctx.me?.team?.id} />
104126

127+
{/* Rate-limit / load-error banner. Sits ABOVE the empty-state row
128+
so a 429 or 5xx is impossible to mistake for "you have no
129+
deployments". Matches the TeamPage error-banner pattern so the
130+
dashboard handles the API's structured error envelope the same
131+
way across pages. */}
132+
{err && (
133+
<div
134+
role="alert"
135+
data-testid="deployments-error"
136+
className="card"
137+
style={{
138+
padding: '10px 14px',
139+
marginBottom: 16,
140+
borderColor: err.rateLimited ? 'var(--amber)' : 'var(--rose)',
141+
color: err.rateLimited ? 'var(--amber)' : 'var(--rose)',
142+
fontSize: 12.5,
143+
}}
144+
>
145+
{err.rateLimited ? (
146+
<>
147+
Too many requests — the deployments list is rate-limited.{' '}
148+
{formatRetryHint(err.retrySeconds)}
149+
</>
150+
) : (
151+
<>Could not load deployments — {err.message}. Reload the page to try again.</>
152+
)}
153+
</div>
154+
)}
155+
105156
{/* W9: human-driven "Create stack" entry-point. The dashboard
106157
stays read-only everywhere else, but POST /stacks/new is
107158
multipart-only — agents can't tar up source either, so this
@@ -238,8 +289,15 @@ export function DeploymentsPage() {
238289
// app_id column. Routing by UUID would 404. app_id is also the
239290
// segment used by /deploy/:id/logs, so the same param threads
240291
// through to the SSE log stream on DeployDetailPage.
292+
//
293+
// T15 P2-2: link directly to /app/deployments/:id — the prefixed
294+
// dashboard route. The unprefixed /deployments/:id path also
295+
// resolves but through LegacyDeploymentRedirect (App.tsx) which
296+
// does a render → <Navigate replace> → render double-hop. The
297+
// legacy route is for external/bookmarked links only; internal
298+
// nav should go straight to the canonical /app/* path.
241299
<Link
242-
to={`/deployments/${d.app_id}`}
300+
to={`/app/deployments/${d.app_id}`}
243301
key={d.id}
244302
className="table-row"
245303
style={{ gridTemplateColumns: '1.5fr 1fr 100px 80px 100px 110px 80px 28px', textDecoration: 'none', color: 'inherit' }}

src/pages/MarketingPage.test.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,61 @@ describe('MarketingPage — legal footer links (W12 H15)', () => {
5959
expect(a!.getAttribute('href')).toBe('https://instanode.dev/llms.txt')
6060
})
6161
})
62+
63+
// ─── T18 P1-2 / P1-4 / P1-6 regression guards ──────────────────────────────
64+
//
65+
// These pin the post-bug-bash invariants on the homepage 'For agents' nav
66+
// link and the page's own claim copy. The nav-drift guard (T18 P1-1) is
67+
// covered by sibling PR #107's MarketingNav shared module, so this PR
68+
// sticks to the residual fixes #107 did not address.
69+
describe('MarketingPage — homepage nav drift (T18 P1-2)', () => {
70+
it("homepage nav 'For agents' link points at /for-agents (not '#for-agents')", () => {
71+
const { container } = render(
72+
<MemoryRouter initialEntries={['/']}>
73+
<MarketingPage />
74+
</MemoryRouter>,
75+
)
76+
// T18 P1-2: every 'For agents' anchor in the homepage nav routes to
77+
// the dedicated page. (The page also still has an `id="for-agents"`
78+
// section so direct hash-links continue to scroll; this test just
79+
// pins that the nav label routes consistently.)
80+
const anchors = Array.from(container.querySelectorAll('a')).filter(
81+
(a) => (a.textContent ?? '').trim() === 'For agents',
82+
)
83+
expect(anchors.length).toBeGreaterThan(0)
84+
for (const a of anchors) {
85+
expect(a.getAttribute('href')).toBe('/for-agents')
86+
}
87+
})
88+
})
89+
90+
describe('MarketingPage — claim consistency (T18 P1-4 / P1-6)', () => {
91+
it("'Seven services' headline matches the MCP tools card (both say seven, listing webhook)", () => {
92+
const { container } = render(
93+
<MemoryRouter initialEntries={['/']}>
94+
<MarketingPage />
95+
</MemoryRouter>,
96+
)
97+
const text = container.textContent ?? ''
98+
// Headline says "Seven services. One bundle."
99+
expect(text).toMatch(/Seven services\. One bundle\./)
100+
// MCP tools card must NOT say "Six tools registered" (the dropped-
101+
// webhook regression). It must say "Seven" and list webhook.
102+
expect(text).not.toMatch(/Six tools registered/)
103+
expect(text).toMatch(/Seven tools registered/)
104+
expect(text).toMatch(/webhook/)
105+
})
106+
107+
it("Deploy service card claims a build window consistent with content/llms.txt (~60s, not '<10s')", () => {
108+
const { container } = render(
109+
<MemoryRouter initialEntries={['/']}>
110+
<MarketingPage />
111+
</MemoryRouter>,
112+
)
113+
const text = container.textContent ?? ''
114+
// The previous '<10s' claim contradicted content/llms.txt's ~30–90s
115+
// kaniko-build window. The honest number is ~60s.
116+
expect(text).not.toMatch(/<10s/)
117+
expect(text).toMatch(/~60s/)
118+
})
119+
})

src/pages/MarketingPage.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,20 @@ const HOMEPAGE_USECASE_SLUGS = [
7777
// Anchors and route paths used throughout the page. Centralized so we don't
7878
// scatter hardcoded string fragments — easy to update when /pricing or /docs
7979
// gets wired in.
80+
//
81+
// T18 P1-2 (2026-05-20): `forAgents` previously pointed at `#for-agents`,
82+
// a same-page anchor. Every other shell (PublicShell) linked to the
83+
// real `/for-agents` page. Same label, two destinations. Now both point
84+
// at the route — the homepage anchor `id="for-agents"` still scrolls for
85+
// users already on the page, but the nav link routes consistently so
86+
// the standalone page is no longer orphaned from the homepage nav.
8087
const ROUTES = {
8188
signin: '/login',
8289
pricing: '/pricing',
8390
docs: '/docs',
8491
blog: '/blog',
8592
changelog: '/changelog',
86-
forAgents: '#for-agents',
93+
forAgents: '/for-agents',
8794
playground: '#playground',
8895
} as const
8996

@@ -104,7 +111,13 @@ const SERVICES: Service[] = [
104111
// Renamed to avoid the brand-tie-in confusion.
105112
{ id: 'st', name: 'Storage (S3-compatible)', curl: 'POST /storage/new', liveIn: '0.8s' },
106113
{ id: 'wh', name: 'Webhook', curl: 'POST /webhook/new', liveIn: '0.3s' },
107-
{ id: 'dp', name: 'Deploy', curl: 'POST /deploy/new', liveIn: '<10s' },
114+
// T18 P1-6 (2026-05-20): liveIn was '<10s', which contradicted both
115+
// content/llms.txt ("~30–90s") and content/docs/deploy.md ("~30s build")
116+
// by 3–9×. The actual kaniko build pipeline (Dockerfile → image layer
117+
// cache → Service → Ingress → cert-manager) lands in the 30–90s range.
118+
// '~60s' matches docs and llms.txt — a 3–9× shipping-window lie on the
119+
// primary acquisition surface was the headline bug here.
120+
{ id: 'dp', name: 'Deploy', curl: 'POST /deploy/new', liveIn: '~60s' },
108121
]
109122

110123
type Plan = {
@@ -648,7 +661,12 @@ export function MarketingPage() {
648661
{'\n '}{'}'}
649662
{'\n'}{'}'}
650663
</div>
651-
<p>Six tools registered: <code>postgres</code>, <code>redis</code>, <code>mongo</code>, <code>queue</code>, <code>storage</code>, <code>deploy</code>.</p>
664+
{/* T18 P1-4 (2026-05-20): list the same seven tools as
665+
the "Seven services. One bundle." headline (and the
666+
SERVICES array). The earlier "Six tools" copy dropped
667+
webhook — a real MCP tool — and contradicted the same
668+
page. */}
669+
<p>Seven tools registered: <code>postgres</code>, <code>redis</code>, <code>mongo</code>, <code>queue</code>, <code>storage</code>, <code>webhook</code>, <code>deploy</code>.</p>
652670
</div>
653671
</div>
654672

0 commit comments

Comments
 (0)