diff --git a/.agents/skills/update-lifecycle-docs/references/screenshots.md b/.agents/skills/update-lifecycle-docs/references/screenshots.md index 8662c5f9..eebb5739 100644 --- a/.agents/skills/update-lifecycle-docs/references/screenshots.md +++ b/.agents/skills/update-lifecycle-docs/references/screenshots.md @@ -153,11 +153,13 @@ or found to be stale. `keep` means the final pixels and live state were manually reviewed. `replace` and `remove` deliberately fail `bun run check:screenshots` until the referenced debt is resolved. -| Asset | Docs page | User point | UI route/state | Fixture | Viewport/theme | UI revision | Last verified | Review | -| ----------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------- | ------------------------------------------ | ------------- | ------ | -| `/docs/getting-started/explore-environment/environment-ready.png` | `/docs/getting-started/explore-environment` | Recognize a ready Environment and Service | Environment details, selected Service Summary, `Deployed` / `Ready` | Authorized disposable fixture; incidental identity, hostname, UUID, and timestamp values generalized in DOM; cleanup confirmed | 1440×900 / light | `b1df0cd64b4680fb241364be6cab985a2dce1f4f` | 2026-07-24 | `keep` | -| `/docs/getting-started/onboard-repository/repository-list.png` | `/docs/getting-started/onboard-repository` | Find and select a repository for onboarding | `/onboard`, installed but not-onboarded repository filtered | Authorized disposable fixture; incidental identity, hostname, UUID, and timestamp values generalized in DOM; cleanup confirmed | 1440×900 / light | `b1df0cd64b4680fb241364be6cab985a2dce1f4f` | 2026-07-24 | `keep` | -| `/docs/features/lifecycle-ui/environment-list.png` | `/docs/features/lifecycle-ui` | Find, filter, and select an Environment | `/environments`, one `Torn down` Environment filtered | Authorized disposable fixture; incidental identity, hostname, UUID, and timestamp values generalized in DOM; cleanup confirmed | 1440×900 / light | `b1df0cd64b4680fb241364be6cab985a2dce1f4f` | 2026-07-24 | `keep` | +| Asset | Docs page | User point | UI route/state | Fixture | Viewport/theme | UI revision | Last verified | Review | +| ----------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------- | ------------------------------------------------------------------ | ------------- | ------ | +| `/docs/getting-started/explore-environment/environment-ready.png` | `/docs/getting-started/explore-environment` | Recognize a ready Environment and Service | Environment details, selected Service Summary, `Deployed` / `Ready` | Authorized disposable fixture; incidental identity, hostname, UUID, and timestamp values generalized in DOM; cleanup confirmed | 1440×900 / light | `b1df0cd64b4680fb241364be6cab985a2dce1f4f` | 2026-07-24 | `keep` | +| `/docs/getting-started/onboard-repository/repository-list.png` | `/docs/getting-started/onboard-repository` | Find and select a repository for onboarding | `/onboard`, installed but not-onboarded repository filtered | Authorized disposable fixture; incidental identity, hostname, UUID, and timestamp values generalized in DOM; cleanup confirmed | 1440×900 / light | `b1df0cd64b4680fb241364be6cab985a2dce1f4f` | 2026-07-24 | `keep` | +| `/docs/features/lifecycle-ui/environment-list.png` | `/docs/features/lifecycle-ui` | Find, filter, and select an Environment | `/environments`, one `Torn down` Environment filtered | Authorized disposable fixture; incidental identity, hostname, UUID, and timestamp values generalized in DOM; cleanup confirmed | 1440×900 / light | `b1df0cd64b4680fb241364be6cab985a2dce1f4f` | 2026-07-24 | `keep` | +| `/docs/releases/keycloak-management-client-capabilities.png` | `/docs/releases` | Configure the management client flows | Keycloak management client, capability configuration | Authorized local non-production realm; component crop contains no hostname, identity, or secret | 1440×900 / dark | Keycloak `26.4.7`; Helm `78b680595d4beda3184124312d61d86ce0cc51e7` | 2026-08-01 | `keep` | +| `/docs/releases/keycloak-management-client-scope.png` | `/docs/releases` | Verify least-privilege scope mappings | Dedicated management client scope with full scope off | Authorized local non-production realm; component crop contains no hostname, identity, or secret | 1440×900 / dark | Keycloak `26.4.7`; Helm `78b680595d4beda3184124312d61d86ce0cc51e7` | 2026-08-01 | `keep` | The structural check cannot read meaning from rendered pixels. A `keep` status is a human assertion that the final image contains no secret, personal, diff --git a/.gitignore b/.gitignore index 43d22450..7e3f71a0 100644 --- a/.gitignore +++ b/.gitignore @@ -154,3 +154,4 @@ deployment-issues/ # Local Claude Code state (permission allowlist, session lockfiles) .claude/ +.planning/ diff --git a/documentation-metadata.json b/documentation-metadata.json index 1d46f1d7..891dea80 100644 --- a/documentation-metadata.json +++ b/documentation-metadata.json @@ -43,6 +43,16 @@ "helm-charts": "c0257d1f88b5b30533f490d12f6293fa465b906c", "lifecycle-opentofu": "b14865912608096379c28ef648f7ed99538b5600" } + }, + "2026-08-01-adopt-lifecycle-mcp-browser-validation": { + "verifiedOn": "2026-08-01", + "sources": { + "lifecycle": "46af94b799ef43c9e88d48212ec6333a52d56e9f", + "lifecycle-ui": "69331dc3876e4aec646a893114739a14755eebd7", + "lifecycle-cli": "3f8600dca8c97a715c7ae01c3a3087ba934fa4e1", + "helm-charts": "78b680595d4beda3184124312d61d86ce0cc51e7", + "lifecycle-opentofu": "b14865912608096379c28ef648f7ed99538b5600" + } } } } diff --git a/documentation-style-baseline.json b/documentation-style-baseline.json index 33f4c7d9..efe69eb7 100644 --- a/documentation-style-baseline.json +++ b/documentation-style-baseline.json @@ -178,7 +178,7 @@ "file": "src/pages/docs/features/mcp-server.mdx", "profile": "asd-ste100", "reviewedOn": "2026-08-01", - "sha256": "49addadbc408af37d03f1f2e4f8b12e4ca0dcf03c6e4b7b48bb6a3fee963f582" + "sha256": "69ab72bfb6486d0189fc903d4a3d8e170c578be252d39335b7bea8e7c8b0da36" }, "/docs/features/native-helm-deployment": { "file": "src/pages/docs/features/native-helm-deployment.mdx", @@ -304,7 +304,7 @@ "file": "src/pages/docs/releases/index.mdx", "profile": "asd-ste100", "reviewedOn": "2026-08-01", - "sha256": "db4c73e5c8a52b1785a19cb695fcacae932cdbe68a66814750b29af5b0368261" + "sha256": "b8ee17e3117ba7fc2d5badf0b12ecbd2cd8248750ca9e9e9c6f8d36224808722" }, "/docs/releases/compatibility": { "file": "src/pages/docs/releases/compatibility.mdx", diff --git a/next.config.mjs b/next.config.mjs index 4dc9c771..7bd4e5ff 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -16,6 +16,7 @@ import nextra from "nextra"; import { remarkCodeHike, recmaCodeHike } from "codehike/mdx"; +import { remarkCodeHikeSearch } from "./src/lib/remark-codehike-search.mjs"; /** @type {import('codehike/mdx').CodeHikeConfig} */ export const chConfig = { @@ -26,7 +27,13 @@ export const chConfig = { } export const mdxOptions = { - remarkPlugins: [[remarkCodeHike, chConfig]], + remarkPlugins: [ + [remarkCodeHike, chConfig], + [ + remarkCodeHikeSearch, + { componentName: chConfig.components.code }, + ], + ], recmaPlugins: [[recmaCodeHike, chConfig]], // jsx: true, } diff --git a/package.json b/package.json index 360a6b6b..85eef026 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Lifecycle docs", "main": "index.js", "scripts": { - "build": "bun run build:prep && bun run build:styles && next build", + "build": "bun run build:prep && bun run build:styles && next build && bun run check:search", "build:styles": "tailwindcss -i ./src/styles/globals.css -o public/styles.css", "build:meta": "bun run ./scripts/generateMeta.ts", "build:llms": "bun run ./scripts/generateLlms.ts", @@ -14,6 +14,7 @@ "check:docs": "bun run ./scripts/validateDocs.ts", "check:llms": "bun run ./scripts/generateLlms.ts --check", "check:raw": "bun run ./scripts/generateRawMarkdown.ts --check", + "check:search": "bun run ./scripts/validateSearchIndex.ts", "check:screenshots": "bun run ./scripts/validateDocs.ts --screenshots-only", "check:styles": "bun run ./scripts/validateSte100.ts", "clean": "rimraf src/pages/tags src/lib/data", diff --git a/public/docs/releases/keycloak-management-client-capabilities.png b/public/docs/releases/keycloak-management-client-capabilities.png new file mode 100644 index 00000000..ecdc114c Binary files /dev/null and b/public/docs/releases/keycloak-management-client-capabilities.png differ diff --git a/public/docs/releases/keycloak-management-client-scope.png b/public/docs/releases/keycloak-management-client-scope.png new file mode 100644 index 00000000..c731f8a3 Binary files /dev/null and b/public/docs/releases/keycloak-management-client-scope.png differ diff --git a/public/llms.txt b/public/llms.txt index 9090c1df..a54e48aa 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -88,7 +88,7 @@ This index is generated from the human documentation. Follow the linked page for ## Releases and compatibility -- [Releases](https://uselifecycle.com/docs/releases.md): Find your installed Lifecycle versions and the release information for an upgrade. _(audience: platform-operator, application-developer; last verified: 2026-08-01; baseline: 2026-08-01-lifecycle-mcp-preparation)_ +- [Releases](https://uselifecycle.com/docs/releases.md): Find your installed Lifecycle versions and the release information for an upgrade. _(audience: platform-operator, application-developer; last verified: 2026-08-01; baseline: 2026-08-01-adopt-lifecycle-mcp-browser-validation)_ - [Compatibility and deprecation policy](https://uselifecycle.com/docs/releases/compatibility.md): Select compatible Lifecycle components and prepare a safe upgrade or rollback. _(audience: platform-operator, application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ ## Troubleshooting diff --git a/scripts/validateSearchIndex.ts b/scripts/validateSearchIndex.ts new file mode 100644 index 00000000..d7baa4d6 --- /dev/null +++ b/scripts/validateSearchIndex.ts @@ -0,0 +1,91 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from "node:fs"; +import path from "node:path"; + +type SearchPage = { + title: string; + data: Record; +}; + +type SearchIndex = Record; + +export type SearchIndexContract = { + route: string; + text: string; + source: string; +}; + +export const SEARCH_INDEX_CONTRACTS: SearchIndexContract[] = [ + { + route: "/docs/features/sites", + text: "a1b2c3d4e5", + source: "src/pages/docs/features/sites.mdx", + }, +]; + +export function validateSearchIndexData( + index: SearchIndex, + contracts: SearchIndexContract[] = SEARCH_INDEX_CONTRACTS, +): string[] { + const issues: string[] = []; + + for (const contract of contracts) { + const page = index[contract.route]; + if (!page) { + issues.push( + `${contract.route} is missing from the search index (${contract.source})`, + ); + continue; + } + + const searchableText = [page.title, ...Object.values(page.data)].join("\n"); + if (!searchableText.includes(contract.text)) { + issues.push( + `${contract.route} does not index fenced-code text ${JSON.stringify(contract.text)} (${contract.source})`, + ); + } + } + + return issues; +} + +export async function validateSearchIndex( + rootDir = process.cwd(), +): Promise { + const indexPath = path.join( + rootDir, + "out/_next/static/chunks/nextra-data-en-US.json", + ); + const contents = await fs.promises.readFile(indexPath, "utf8"); + const index = JSON.parse(contents) as SearchIndex; + return validateSearchIndexData(index); +} + +async function runCli() { + const issues = await validateSearchIndex(); + if (issues.length > 0) { + for (const issue of issues) console.error(issue); + process.exit(1); + } + + console.log("Search index validation passed."); +} + +if (import.meta.main) { + await runCli(); +} diff --git a/src/components/codehike/code.tsx b/src/components/codehike/code.tsx index 7ee2ad3e..66a01ed8 100644 --- a/src/components/codehike/code.tsx +++ b/src/components/codehike/code.tsx @@ -39,6 +39,7 @@ import Loader from "@/components/loader"; export const CodeHikeSSR = ({ codeblock, + children: searchIndexText, handlers = [ callout, className, @@ -58,6 +59,8 @@ export const CodeHikeSSR = ({ classes = "", ...props }: CodeHikeProps & Omit) => { + // Nextra indexes this MDX child. The rendered code comes from `codeblock`. + void searchIndexText; const [isClient, setIsClient] = useState(false); const [theme, setTheme] = useState(initialTheme); diff --git a/src/components/codehike/types.ts b/src/components/codehike/types.ts index 5cfbf6c9..15aa1f53 100644 --- a/src/components/codehike/types.ts +++ b/src/components/codehike/types.ts @@ -18,6 +18,7 @@ import { HighlightedCode, AnnotationHandler } from "codehike/code"; export type CodeHikeProps = { codeblock: HighlightedCode; + children?: React.ReactNode; handlers?: AnnotationHandler[]; theme?: string; classes?: string; diff --git a/src/lib/remark-codehike-search.mjs b/src/lib/remark-codehike-search.mjs new file mode 100644 index 00000000..b7353c2d --- /dev/null +++ b/src/lib/remark-codehike-search.mjs @@ -0,0 +1,72 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +function propertyName(property) { + if (property?.type !== "Property" || property.computed) return null; + if (property.key?.type === "Identifier") return property.key.name; + if (property.key?.type === "Literal") return property.key.value; + return null; +} + +function stringProperty(objectExpression, name) { + if (objectExpression?.type !== "ObjectExpression") return null; + + for (const property of objectExpression.properties || []) { + if (propertyName(property) !== name) continue; + if (property.value?.type !== "Literal") return null; + return typeof property.value.value === "string" + ? property.value.value + : null; + } + + return null; +} + +export function codeHikeSource(node, componentName = "Code") { + if (node?.type !== "mdxJsxFlowElement" || node.name !== componentName) { + return null; + } + + const attribute = (node.attributes || []).find( + (item) => item?.type === "mdxJsxAttribute" && item.name === "codeblock", + ); + const program = attribute?.value?.data?.estree; + const expression = program?.body?.[0]?.expression; + + return ( + stringProperty(expression, "value") ?? stringProperty(expression, "code") + ); +} + +function addSearchText(node, componentName) { + const source = codeHikeSource(node, componentName); + if (source !== null) { + node.children = [{ type: "text", value: source }]; + } + + for (const child of node?.children || []) { + addSearchText(child, componentName); + } +} + +/** + * Restores fenced-code text after CodeHike converts Markdown code nodes into + * MDX components. Nextra's later structurizer can then include the text in its + * FlexSearch data. + */ +export function remarkCodeHikeSearch({ componentName = "Code" } = {}) { + return (tree) => addSearchText(tree, componentName); +} diff --git a/src/pages/docs/features/mcp-server.mdx b/src/pages/docs/features/mcp-server.mdx index b3923ff2..d2bb3524 100644 --- a/src/pages/docs/features/mcp-server.mdx +++ b/src/pages/docs/features/mcp-server.mdx @@ -127,7 +127,7 @@ The page shows: - an enable or disable control - the **Allow changes** control - capability and tool information -- one actionable issue when enablement fails +- one issue when enablement fails Enablement is one bounded request. Lifecycle configures the required Keycloak sign-in settings and verifies them against the MCP URL. diff --git a/src/pages/docs/releases/index.mdx b/src/pages/docs/releases/index.mdx index 8838f995..f74fb395 100644 --- a/src/pages/docs/releases/index.mdx +++ b/src/pages/docs/releases/index.mdx @@ -5,7 +5,7 @@ audience: - platform-operator - application-developer lastVerified: "2026-08-01" -verificationBaseline: "2026-08-01-lifecycle-mcp-preparation" +verificationBaseline: "2026-08-01-adopt-lifecycle-mcp-browser-validation" contentProfile: asd-ste100 tags: - release @@ -13,6 +13,8 @@ tags: - changelog --- +import { Image } from "@lifecycle-docs/components"; + Record your installed versions before you upgrade or troubleshoot Lifecycle. The chart, application, UI, identity service, CLI, and infrastructure can have different versions. @@ -63,41 +65,219 @@ lfc --version ## Adopt Lifecycle MCP -The compatible Helm chart includes the credentials that Lifecycle API needs -for MCP Keycloak configuration. Administrator enablement and change tools are -off by default. +These steps were verified with Lifecycle chart `0.9.11`. This chart bundles +`lifecycle-keycloak` chart `0.7.6`. + +Lifecycle MCP and its change tools are off by default. +Use this procedure for an installed Lifecycle deployment. + +### Before you enable MCP + +- Confirm that Lifecycle authentication works at its canonical public URL. +- Confirm that Lifecycle web has the management client credentials and a valid `ENCRYPTION_KEY`. +- Back up the Keycloak realm and its matching Kubernetes Secrets. +- Confirm that the installed chart includes the MCP Keycloak credentials. + +For a new bundled Keycloak realm, the initial import creates the management +client. The chart creates the Secret and injects the credential into Lifecycle +web. Do not create a second MCP client secret. Continue with +[Verify the management client](#verify-the-management-client). + +For an existing realm, complete the following migration first. + +### Prepare the existing realm + +1. Select the Lifecycle realm in Keycloak Admin Console. + The console can open in the `master` realm. +2. Record the effective management client values. +3. Record the anonymous client registration policies. + +For the Lifecycle umbrella chart, use these values: + +- Client ID: `keycloak.clients.lifecycleApiKeycloakManagement.clientId` +- Secret reference: `keycloak.clients.lifecycleApiKeycloakManagement.clientSecret.secretKeyRef` + +The default Client ID is `lifecycle-api-keycloak-management`. +For the standalone `lifecycle-keycloak` chart, remove the `keycloak.` prefix. + +### Create the management client + +1. Open **Clients → Create client**. +2. Set **Client type** to **OpenID Connect**. +3. Set **Client ID** to the effective Helm value. + Do not use the display name as the Client ID. +4. Optionally set **Name** to `Lifecycle API Keycloak management`. +5. Select **Next**. +6. Turn **Client authentication** on. +7. Turn **Service accounts roles** on. +8. Turn the standard, implicit, and direct-access flows off. +9. Leave the login URLs empty. +10. Select **Save**. + +Keycloak capability settings show client authentication and service account roles on, with interactive authentication flows off. + +### Configure and connect the client secret + +Open **Credentials**. Confirm that **Client Authenticator** is **Client Id and +Secret**. + +The management client secret must have the same value in Keycloak and the +referenced Kubernetes Secret. Lifecycle web reads the credential through these +environment variables: + +- `KEYCLOAK_MANAGEMENT_CLIENT_ID` +- `KEYCLOAK_MANAGEMENT_CLIENT_SECRET` + +When you use the Lifecycle umbrella chart, do not add these variables manually. +The chart sets them only on Lifecycle web from these values: + +- Client ID: `keycloak.clients.lifecycleApiKeycloakManagement.clientId` +- Secret name and key: + `keycloak.clients.lifecycleApiKeycloakManagement.clientSecret.secretKeyRef` + +The Secret key defaults to `clientSecret`. If the Secret name is empty, the +chart uses its release-specific generated Secret. + +If an external secret manager owns the credential, use this pattern: + +```yaml +keycloak: + clients: + lifecycleApiKeycloakManagement: + enabled: true + clientId: lifecycle-api-keycloak-management + clientSecret: + secretKeyRef: + name: + key: clientSecret + secrets: + apiKeycloakManagement: + enabled: false +``` + +Before the Helm upgrade, confirm that the Secret exists in the release +namespace. For the standalone `lifecycle-keycloak` chart, remove the +`keycloak.` prefix from the values. + +For a non-chart deployment, set both environment variables on Lifecycle web +only. + +The Admin Console generates the secret. It cannot set an existing secret +value. Use one approved method: + +- Use Keycloak admin automation to set the client secret from the referenced + Secret. +- Alternatively, regenerate the Keycloak secret. + Store it in the referenced Secret. Restart Lifecycle web. + +Do not print or log the secret. A Helm value change does not rotate an existing +Keycloak client secret. + +Do not put this secret in an MCP client configuration. MCP clients use OAuth +dynamic registration. + +The secret is only the credential step. Complete the client settings, service +roles, scope mappings, and token verification before enablement. + +### Configure direct service roles + +1. Open **Service accounts roles**. +2. Turn **Hide inherited roles** on. +3. If Keycloak assigned direct roles by default, remove them. +4. Select **Assign role → Client roles**. +5. Assign `manage-clients` from the `realm-management` client. +6. Assign `manage-realm` from the `realm-management` client. +7. Confirm that no other direct role is assigned. + +### Configure client scope mappings + +1. Open **Client scopes → Setup**. +2. Keep the dedicated scope and the `basic` and `roles` default scopes. +3. Remove all other default and optional scope assignments. +4. Open the dedicated client scope. +5. Open **Scope**. +6. Turn **Full scope allowed** off. +7. Select **Assign role → Client roles**. +8. Assign `manage-clients` and `manage-realm` from `realm-management`. +9. Confirm that no other direct scope role is assigned. + +Keycloak can add realm defaults to a client that you create in the Admin +Console. Remove those defaults after you assign the service account roles. + +The dedicated Keycloak scope has full scope access off and only the two required realm-management roles. + +### Verify the management client + +1. Request a client-credentials token with an approved administration tool. +2. Do not print or log the secret or token. +3. Inspect `resource_access.realm-management.roles` in the token. +4. Confirm that it includes `manage-clients` and `manage-realm`. + +The token can contain composite roles. Do not require an exact role count for +this token check. + +### Protect the existing Keycloak configuration + +Lifecycle creates or reconciles reserved Keycloak objects during enablement. +These objects include the `mcp` scope, five mappers, scope mappings, profiles, +and anonymous client registration policies. + +Enablement removes the exact stock `Trusted Hosts` policy when it exists. +It accepts an absent stock policy. It fails if customized or duplicate reserved +objects conflict with the required configuration. + +Disable does not restore the previous Keycloak configuration. Keep the realm +backup until you complete validation. + +### Enable Lifecycle MCP + +1. Open **Platform administration → Lifecycle MCP**. +2. Confirm the displayed **Remote MCP URL**. +3. Turn **Enable Lifecycle MCP** on. +4. Confirm that no issue alert appears. +5. Confirm that the status is **Read only**. +6. Confirm that read tools are available and change tools are off. +7. When agents need change tools, turn **Allow changes** on. +8. Add the URL to a compatible MCP client. +9. Complete Lifecycle OAuth sign-in. -For a new bundled Keycloak realm, the initial realm import creates the required -Lifecycle API credentials. +Lifecycle verifies the Keycloak and OAuth contract before it keeps MCP on. +Lifecycle MCP accepts OAuth bearer tokens only. -If the Lifecycle realm already exists, complete this migration before you -enable Lifecycle MCP: +### Recover from failed enablement -1. Create an enabled confidential OpenID Connect client named `lifecycle-api-keycloak-management`. -2. Enable service accounts for the client. -3. Disable the standard, implicit, and direct-access flows. -4. Set full-scope access to off. -5. Assign only `manage-clients` and `manage-realm` from the `realm-management` client to the service account. -6. Add the same roles to the client scope mappings. -7. Configure its secret to match the Secret referenced by `keycloak.clients.lifecycleApiKeycloakManagement.clientSecret.secretKeyRef`. -8. Verify that its client-credentials token contains the required roles. +If verification fails, Lifecycle keeps MCP off. Correct the reported issue. +Then, retry enablement. -Use your approved external Keycloak administration process. Do not print or -log the credential. Credential disclosure can compromise Lifecycle Keycloak. +For a configuration issue, verify these details: -Before the first enablement, record or back up the current anonymous client -registration policy. Enablement can remove the exact stock `Trusted Hosts` -component. Disable does not restore it. +- `APP_HOST` is the canonical public Lifecycle URL. +- `APP_HOST` uses HTTPS unless it is a loopback URL. +- The OIDC issuer and JWKS endpoints are reachable by Lifecycle. +- OIDC discovery advertises the exact issuer and dynamic client registration. +- OIDC discovery advertises `S256` PKCE and a usable `RS256` signing key. +- `ENCRYPTION_KEY` is a 64-character hexadecimal value. +- Lifecycle web has the Keycloak management client ID and secret. -1. Confirm the canonical Lifecycle MCP URL. -2. Open **Platform administration → Lifecycle MCP**. -3. Enable Lifecycle MCP. -4. When agents need change tools, enable **Allow changes**. -5. Add the URL to a compatible client. -6. Complete Lifecycle OAuth sign-in. +A failed probe can leave managed objects or a +`Lifecycle MCP enablement probe` client. If cleanup fails and the client +remains, remove the probe client. -The chart declares the API credentials in its initial realm import. Lifecycle -configures and verifies the MCP OAuth contract during enablement. +Disabling MCP is a containment action, not a rollback. A Helm upgrade does not +repair a partially configured realm. Restore the saved realm only when you +must roll back the Keycloak changes. -Lifecycle MCP accepts OAuth bearer tokens only. Read [Lifecycle -MCP](/docs/features/mcp-server) for tools, permissions, and troubleshooting. +Read [Lifecycle MCP](/docs/features/mcp-server) for tools, permissions, client +setup, and troubleshooting. diff --git a/tests/config/remarkCodeHikeSearch.test.ts b/tests/config/remarkCodeHikeSearch.test.ts new file mode 100644 index 00000000..2ea3d7ce --- /dev/null +++ b/tests/config/remarkCodeHikeSearch.test.ts @@ -0,0 +1,89 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, test } from "bun:test"; +import { remarkCodeHike } from "codehike/mdx"; +import remarkMdx from "remark-mdx"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; +import { + codeHikeSource, + remarkCodeHikeSearch, +} from "../../src/lib/remark-codehike-search.mjs"; + +const codeHikeConfig = { + components: { code: "Code" }, + syntaxHighlighting: { theme: "github-dark" }, +}; + +type SearchNode = { + type?: unknown; + name?: unknown; + children?: unknown; +}; + +function findCode(node: unknown): SearchNode | null { + if (!node || typeof node !== "object") return null; + const candidate = node as SearchNode; + if (candidate.type === "mdxJsxFlowElement" && candidate.name === "Code") { + return candidate; + } + + const children = Array.isArray(candidate.children) ? candidate.children : []; + for (const child of children) { + const result = findCode(child); + if (result) return result; + } + return null; +} + +describe("CodeHike search indexing", () => { + test("restores fenced-code text after CodeHike transforms the node", async () => { + const processor = unified() + .use(remarkParse) + .use(remarkMdx) + .use(remarkCodeHike, codeHikeConfig) + .use(remarkCodeHikeSearch, { componentName: "Code" }); + const tree = await processor.run( + processor.parse("# Sites\n\n```bash\nlfc sites get a1b2c3d4e5\n```"), + ); + const code = findCode(tree); + if (!code) throw new Error("CodeHike did not produce the Code component"); + + expect(codeHikeSource(code)).toBe("lfc sites get a1b2c3d4e5"); + expect(code.children).toEqual([ + { type: "text", value: "lfc sites get a1b2c3d4e5" }, + ]); + }); + + test("does not add search text to a different MDX component", () => { + const tree = { + type: "root", + children: [ + { + type: "mdxJsxFlowElement", + name: "Example", + attributes: [], + children: [], + }, + ], + }; + + remarkCodeHikeSearch({ componentName: "Code" })(tree); + + expect(tree.children[0].children).toEqual([]); + }); +}); diff --git a/tests/scripts/validateSearchIndex.test.ts b/tests/scripts/validateSearchIndex.test.ts new file mode 100644 index 00000000..965b9f0d --- /dev/null +++ b/tests/scripts/validateSearchIndex.test.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, test } from "bun:test"; +import { validateSearchIndexData } from "../../scripts/validateSearchIndex"; + +const contract = { + route: "/docs/features/sites", + text: "a1b2c3d4e5", + source: "src/pages/docs/features/sites.mdx", +}; + +describe("search index validation", () => { + test("accepts fenced-code text in the configured page", () => { + const issues = validateSearchIndexData( + { + "/docs/features/sites": { + title: "Sites", + data: { "upload#Upload": "lfc sites get a1b2c3d4e5" }, + }, + }, + [contract], + ); + + expect(issues).toEqual([]); + }); + + test("reports fenced-code text missing from the configured page", () => { + const issues = validateSearchIndexData( + { + "/docs/features/sites": { + title: "Sites", + data: { "upload#Upload": "Upload a site." }, + }, + }, + [contract], + ); + + expect(issues).toEqual([ + '/docs/features/sites does not index fenced-code text "a1b2c3d4e5" (src/pages/docs/features/sites.mdx)', + ]); + }); +});