@lukoweb/apitogo 0.2.22 → 0.2.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -94
- package/cli.js +13 -13
- package/client.d.ts +8 -8
- package/dist/cli/cli.js +561 -232
- package/dist/declarations/cli/platform/client/platform-api.d.ts +18 -0
- package/dist/declarations/config/validators/InputNavigationSchema.d.ts +7 -7
- package/dist/declarations/config/validators/ZudokuConfig.d.ts +1 -1
- package/docs/cli/commands.md +75 -75
- package/docs/cli/go-live.md +123 -123
- package/docs/cli/overview.md +91 -91
- package/docs/cli/setup.md +141 -141
- package/docs/cli/shape.md +127 -127
- package/docs/components/alert.mdx +130 -130
- package/docs/components/badge.mdx +70 -70
- package/docs/components/button.mdx +132 -132
- package/docs/components/callout.mdx +112 -112
- package/docs/components/card.mdx +104 -104
- package/docs/components/checkbox.mdx +72 -72
- package/docs/components/client-only.mdx +79 -79
- package/docs/components/code-tabs.mdx +179 -179
- package/docs/components/dialog.mdx +167 -167
- package/docs/components/head.mdx +200 -200
- package/docs/components/icons.mdx +27 -27
- package/docs/components/input.mdx +96 -96
- package/docs/components/label.mdx +86 -86
- package/docs/components/link.mdx +242 -242
- package/docs/components/markdown.mdx +151 -151
- package/docs/components/mermaid.mdx +81 -81
- package/docs/components/playground.mdx +85 -85
- package/docs/components/secret.mdx +79 -79
- package/docs/components/select.mdx +176 -176
- package/docs/components/shadcn.mdx +73 -73
- package/docs/components/slider.mdx +108 -108
- package/docs/components/slot.mdx +119 -119
- package/docs/components/stepper.mdx +138 -138
- package/docs/components/switch.mdx +96 -96
- package/docs/components/syntax-highlight.mdx +602 -602
- package/docs/components/textarea.mdx +78 -78
- package/docs/components/tooltip.mdx +195 -195
- package/docs/components/typography.mdx +61 -61
- package/docs/components.mdx +60 -60
- package/docs/concepts/auth-provider-api-identities.md +105 -105
- package/docs/configuration/ai-assistants.md +65 -65
- package/docs/configuration/api-catalog.md +109 -109
- package/docs/configuration/api-reference.md +397 -397
- package/docs/configuration/authentication-auth0.md +181 -181
- package/docs/configuration/authentication-azure-ad.md +245 -245
- package/docs/configuration/authentication-clerk.md +117 -117
- package/docs/configuration/authentication-firebase.md +69 -69
- package/docs/configuration/authentication-pingfederate.md +143 -143
- package/docs/configuration/authentication-supabase.md +233 -233
- package/docs/configuration/authentication.md +159 -159
- package/docs/configuration/build-configuration.mdx +147 -147
- package/docs/configuration/docs.md +282 -282
- package/docs/configuration/footer.mdx +118 -118
- package/docs/configuration/gateway.md +221 -221
- package/docs/configuration/llms.md +90 -90
- package/docs/configuration/make-config.mdx +124 -124
- package/docs/configuration/manifest.md +128 -128
- package/docs/configuration/navigation.mdx +421 -421
- package/docs/configuration/overview.md +166 -166
- package/docs/configuration/plans.md +153 -153
- package/docs/configuration/protected-routes.md +150 -150
- package/docs/configuration/search.md +164 -164
- package/docs/configuration/sentry.mdx +44 -44
- package/docs/configuration/site.md +124 -124
- package/docs/configuration/slots.mdx +125 -125
- package/docs/configuration/vite-config.md +18 -18
- package/docs/custom-plugins.md +285 -285
- package/docs/customization/colors-theme.mdx +278 -278
- package/docs/customization/fonts.md +110 -110
- package/docs/customization/landing-page.md +188 -188
- package/docs/deploy/apache-nginx.md +41 -41
- package/docs/deploy/apitogo.md +17 -17
- package/docs/deploy/cloudflare-pages.md +74 -74
- package/docs/deploy/direct-upload.md +17 -17
- package/docs/deploy/github-pages.md +49 -49
- package/docs/deploy/vercel.md +101 -101
- package/docs/deployment.md +9 -9
- package/docs/extending/events.md +124 -124
- package/docs/guides/custom-pages.md +110 -110
- package/docs/guides/environment-variables.md +94 -94
- package/docs/guides/managing-api-keys-and-identities.md +89 -89
- package/docs/guides/mermaid.mdx +70 -70
- package/docs/guides/navigation-rules.mdx +203 -203
- package/docs/guides/processors.mdx +234 -234
- package/docs/guides/static-files.md +55 -55
- package/docs/guides/transforming-examples.md +156 -156
- package/docs/guides/using-multiple-apis.md +87 -87
- package/docs/markdown/admonitions.md +128 -128
- package/docs/markdown/code-blocks.md +196 -196
- package/docs/markdown/frontmatter.md +173 -173
- package/docs/markdown/mdx.md +68 -68
- package/docs/markdown/overview.md +275 -275
- package/docs/mcp/go-live.md +100 -100
- package/docs/mcp/overview.md +76 -76
- package/docs/mcp/setup.md +173 -173
- package/docs/mcp/shape.md +77 -77
- package/docs/mcp/tools.md +102 -102
- package/docs/plugins.md +24 -24
- package/docs/quickstart.md +72 -72
- package/docs/writing.mdx +72 -72
- package/package.json +1 -1
- package/src/app/ZuploBuildConfig.ts +36 -36
- package/src/app/apitogoBrandTheme.ts +47 -47
- package/src/app/defaultTheme.css +77 -77
- package/src/app/demo-cdn.html +37 -37
- package/src/app/demo.html +21 -21
- package/src/app/demo.tsx +77 -77
- package/src/app/entry.client.tsx +107 -107
- package/src/app/entry.server.tsx +160 -160
- package/src/app/env.ts +40 -40
- package/src/app/main.css +400 -400
- package/src/app/main.tsx +167 -167
- package/src/app/polyfills.ts +47 -47
- package/src/app/processRoutes.tsx +51 -51
- package/src/app/sentry.ts +24 -24
- package/src/app/standalone.html +23 -23
- package/src/app/standalone.tsx +60 -60
- package/src/app/utils/createRedirectRoutes.ts +11 -11
- package/src/config/apitogo-config-core.ts +7 -3
- package/src/config/apitogo-config-loader.browser.ts +17 -17
- package/src/config/apitogo-config-loader.node.ts +88 -88
- package/src/config/apitogo-config-loader.ts +6 -6
- package/src/config/build-apitogo-config.ts +61 -61
- package/src/config/config.ts +74 -74
- package/src/config/create-plugin.ts +71 -71
- package/src/config/default-landing-content.ts +149 -149
- package/src/config/file-exists.ts +6 -6
- package/src/config/gateway-limits.ts +128 -128
- package/src/config/landing-manifest.ts +11 -11
- package/src/config/landing-openapi-showcase.ts +124 -124
- package/src/config/loader.ts +375 -375
- package/src/config/local-manifest.ts +545 -545
- package/src/config/resolve-modules.ts +354 -354
- package/src/config/v2/__fixtures__/scenario-a-docs-only.json +6 -6
- package/src/config/v2/__fixtures__/scenario-b-managed-origin.json +14 -14
- package/src/config/v2/__fixtures__/scenario-c-proxy-oidc.json +15 -15
- package/src/config/v2/__fixtures__/scenario-h-full-stack.json +28 -28
- package/src/config/v2/docs-navigation.ts +72 -72
- package/src/config/v2/errors.ts +42 -42
- package/src/config/v2/gateway-reference.ts +220 -220
- package/src/config/v2/gateway-request.ts +123 -123
- package/src/config/v2/home-redirect.ts +98 -98
- package/src/config/v2/index.ts +126 -126
- package/src/config/v2/init.ts +273 -273
- package/src/config/v2/layout.ts +237 -237
- package/src/config/v2/ledger.ts +235 -235
- package/src/config/v2/link-directory.ts +34 -0
- package/src/config/v2/load.ts +70 -70
- package/src/config/v2/manifest.ts +452 -452
- package/src/config/v2/parse.ts +214 -214
- package/src/config/v2/paths.ts +54 -54
- package/src/config/v2/portal-profile.ts +88 -88
- package/src/config/v2/resolve.ts +188 -188
- package/src/config/v2/rules.ts +714 -714
- package/src/config/v2/schema.ts +245 -245
- package/src/config/v2/services.ts +663 -663
- package/src/config/v2/workspace.ts +3 -2
- package/src/config/v2/wrapper.ts +24 -2
- package/src/config/validators/BuildSchema.ts +95 -95
- package/src/config/validators/HeaderNavigationSchema.ts +44 -44
- package/src/config/validators/InputNavigationSchema.test-d.ts +178 -178
- package/src/config/validators/InputNavigationSchema.ts +206 -206
- package/src/config/validators/ModulesSchema.ts +502 -502
- package/src/config/validators/NavigationSchema.ts +352 -352
- package/src/config/validators/ProtectedRoutesSchema.ts +35 -35
- package/src/config/validators/ZudokuConfig.ts +792 -792
- package/src/config/validators/auth.ts +3 -3
- package/src/config/validators/reason-codes.ts +7 -7
- package/src/env.d.ts +11 -11
- package/src/index.ts +83 -83
- package/src/lib/MissingIcon.tsx +22 -22
- package/src/lib/assets/language-icons/bun.tsx +50 -50
- package/src/lib/assets/language-icons/c.tsx +31 -31
- package/src/lib/assets/language-icons/commonlisp.tsx +22 -22
- package/src/lib/assets/language-icons/cpp.tsx +35 -35
- package/src/lib/assets/language-icons/csharp.tsx +35 -35
- package/src/lib/assets/language-icons/css.tsx +36 -36
- package/src/lib/assets/language-icons/dart.tsx +39 -39
- package/src/lib/assets/language-icons/elixir.tsx +19 -19
- package/src/lib/assets/language-icons/go.tsx +19 -19
- package/src/lib/assets/language-icons/graphql.tsx +19 -19
- package/src/lib/assets/language-icons/html.tsx +24 -24
- package/src/lib/assets/language-icons/java.tsx +35 -35
- package/src/lib/assets/language-icons/javascript.tsx +11 -11
- package/src/lib/assets/language-icons/json.tsx +19 -19
- package/src/lib/assets/language-icons/kotlin.tsx +30 -30
- package/src/lib/assets/language-icons/markdown.tsx +19 -19
- package/src/lib/assets/language-icons/mdx.tsx +23 -23
- package/src/lib/assets/language-icons/npm.tsx +15 -15
- package/src/lib/assets/language-icons/objectivec.tsx +23 -23
- package/src/lib/assets/language-icons/ocaml.tsx +34 -34
- package/src/lib/assets/language-icons/php.tsx +19 -19
- package/src/lib/assets/language-icons/pnpm.tsx +32 -32
- package/src/lib/assets/language-icons/powershell.tsx +27 -27
- package/src/lib/assets/language-icons/python.tsx +23 -23
- package/src/lib/assets/language-icons/react.tsx +21 -21
- package/src/lib/assets/language-icons/ruby.tsx +19 -19
- package/src/lib/assets/language-icons/rust.tsx +19 -19
- package/src/lib/assets/language-icons/scala.tsx +19 -19
- package/src/lib/assets/language-icons/shell.tsx +19 -19
- package/src/lib/assets/language-icons/swift.tsx +19 -19
- package/src/lib/assets/language-icons/toml.tsx +23 -23
- package/src/lib/assets/language-icons/typescript.tsx +23 -23
- package/src/lib/assets/language-icons/xml.tsx +19 -19
- package/src/lib/assets/language-icons/yaml.tsx +23 -23
- package/src/lib/assets/language-icons/yarn.tsx +17 -17
- package/src/lib/assets/language-icons/zig.tsx +32 -32
- package/src/lib/auth/issuer.ts +34 -34
- package/src/lib/authentication/AuthenticationPlugin.tsx +33 -33
- package/src/lib/authentication/auth-header-nav.ts +46 -46
- package/src/lib/authentication/authentication.ts +51 -51
- package/src/lib/authentication/components/CallbackHandler.tsx +45 -45
- package/src/lib/authentication/components/OAuthErrorPage.tsx +197 -197
- package/src/lib/authentication/components/ProductionUnlockPage.tsx +26 -26
- package/src/lib/authentication/components/SignIn.tsx +46 -46
- package/src/lib/authentication/components/SignOut.tsx +15 -15
- package/src/lib/authentication/components/SignUp.tsx +48 -48
- package/src/lib/authentication/constants.ts +1 -1
- package/src/lib/authentication/errors.ts +34 -34
- package/src/lib/authentication/header-nav-filter.ts +35 -35
- package/src/lib/authentication/hook.ts +141 -141
- package/src/lib/authentication/providers/auth0.tsx +91 -91
- package/src/lib/authentication/providers/azureb2c.tsx +223 -223
- package/src/lib/authentication/providers/clerk.tsx +224 -224
- package/src/lib/authentication/providers/dev-portal-auth-pages.tsx +602 -602
- package/src/lib/authentication/providers/dev-portal-constants.ts +38 -38
- package/src/lib/authentication/providers/dev-portal-demo.ts +151 -151
- package/src/lib/authentication/providers/dev-portal-utils.ts +279 -279
- package/src/lib/authentication/providers/dev-portal.tsx +448 -448
- package/src/lib/authentication/providers/firebase.tsx +486 -486
- package/src/lib/authentication/providers/openid.tsx +516 -516
- package/src/lib/authentication/providers/supabase.tsx +412 -412
- package/src/lib/authentication/providers/util.ts +26 -26
- package/src/lib/authentication/state.ts +180 -180
- package/src/lib/authentication/ui/AuthCard.tsx +7 -7
- package/src/lib/authentication/ui/EmailLinkCallbackUi.tsx +129 -129
- package/src/lib/authentication/ui/EmailLinkSentUi.tsx +105 -105
- package/src/lib/authentication/ui/EmailLinkSignInUi.tsx +100 -100
- package/src/lib/authentication/ui/EmailVerificationUi.tsx +128 -128
- package/src/lib/authentication/ui/ZudokuAuthUi.tsx +620 -620
- package/src/lib/authentication/ui/icons/Apple.tsx +10 -10
- package/src/lib/authentication/ui/icons/Facebook.tsx +15 -15
- package/src/lib/authentication/ui/icons/Github.tsx +16 -16
- package/src/lib/authentication/ui/icons/Google.tsx +16 -16
- package/src/lib/authentication/ui/icons/Microsoft.tsx +12 -12
- package/src/lib/authentication/ui/icons/X.tsx +10 -10
- package/src/lib/authentication/utils/relativeRedirectUrl.ts +16 -16
- package/src/lib/components/AiAssistantMenuItems.tsx +102 -102
- package/src/lib/components/AnchorLink.tsx +24 -24
- package/src/lib/components/Autocomplete.tsx +123 -123
- package/src/lib/components/Banner.tsx +52 -52
- package/src/lib/components/Bootstrap.tsx +77 -77
- package/src/lib/components/BuildCheck.tsx +87 -87
- package/src/lib/components/CategoryHeading.tsx +19 -19
- package/src/lib/components/ClientOnly.tsx +19 -19
- package/src/lib/components/DeveloperHint.tsx +39 -39
- package/src/lib/components/ErrorPage.tsx +26 -26
- package/src/lib/components/Footer.tsx +146 -146
- package/src/lib/components/Framed.tsx +51 -51
- package/src/lib/components/Header.tsx +261 -261
- package/src/lib/components/HeaderAuthActions.tsx +32 -32
- package/src/lib/components/HeaderNavigation.tsx +176 -176
- package/src/lib/components/Heading.tsx +84 -84
- package/src/lib/components/InlineCode.tsx +15 -15
- package/src/lib/components/LandingLayout.tsx +27 -27
- package/src/lib/components/LanguageIcon.tsx +199 -199
- package/src/lib/components/Layout.tsx +59 -59
- package/src/lib/components/Main.tsx +52 -52
- package/src/lib/components/Markdown.tsx +55 -55
- package/src/lib/components/Mermaid.tsx +74 -74
- package/src/lib/components/Meta.tsx +46 -46
- package/src/lib/components/MobileTopNavigation.tsx +283 -283
- package/src/lib/components/MultiSelect.tsx +86 -86
- package/src/lib/components/NotFoundPage.tsx +37 -37
- package/src/lib/components/PageProgress.tsx +28 -28
- package/src/lib/components/PagefindSearchMeta.tsx +14 -14
- package/src/lib/components/Pagination.tsx +44 -44
- package/src/lib/components/PathRenderer.tsx +61 -61
- package/src/lib/components/PluginHeads.tsx +17 -17
- package/src/lib/components/Search.tsx +70 -70
- package/src/lib/components/SiteTitle.tsx +58 -58
- package/src/lib/components/Slot.tsx +65 -65
- package/src/lib/components/Spinner.tsx +5 -5
- package/src/lib/components/StatusPage.tsx +96 -96
- package/src/lib/components/ThemeSwitch.tsx +99 -99
- package/src/lib/components/TopNavigation.tsx +90 -90
- package/src/lib/components/Typography.tsx +12 -12
- package/src/lib/components/Zudoku.tsx +105 -105
- package/src/lib/components/cache.ts +27 -27
- package/src/lib/components/context/RenderContext.ts +11 -11
- package/src/lib/components/context/RouterEventsEmitter.tsx +19 -19
- package/src/lib/components/context/SlotProvider.tsx +149 -149
- package/src/lib/components/context/ViewportAnchorContext.tsx +137 -137
- package/src/lib/components/context/ZudokuContext.ts +140 -140
- package/src/lib/components/context/ZudokuProvider.tsx +20 -20
- package/src/lib/components/context/ZudokuReactContext.tsx +17 -17
- package/src/lib/components/index.ts +36 -36
- package/src/lib/components/navigation/Navigation.tsx +51 -51
- package/src/lib/components/navigation/NavigationBadge.tsx +48 -48
- package/src/lib/components/navigation/NavigationCategory.tsx +157 -157
- package/src/lib/components/navigation/NavigationFilterContext.tsx +28 -28
- package/src/lib/components/navigation/NavigationFilterInput.tsx +35 -35
- package/src/lib/components/navigation/NavigationItem.tsx +181 -181
- package/src/lib/components/navigation/NavigationWrapper.tsx +49 -49
- package/src/lib/components/navigation/PoweredByZudoku.tsx +39 -39
- package/src/lib/components/navigation/Toc.tsx +137 -137
- package/src/lib/components/navigation/ZudokuLogo.tsx +25 -25
- package/src/lib/components/navigation/ZuploLogo.tsx +14 -14
- package/src/lib/components/navigation/utils.ts +234 -234
- package/src/lib/core/RouteGuard.tsx +260 -260
- package/src/lib/core/ZudokuContext.ts +272 -272
- package/src/lib/core/plugins.ts +139 -139
- package/src/lib/core/react-query.ts +1 -1
- package/src/lib/core/router.ts +1 -1
- package/src/lib/core/transform-config.ts +67 -67
- package/src/lib/demo/DemoAnnouncement.tsx +17 -17
- package/src/lib/errors/ErrorAlert.tsx +28 -28
- package/src/lib/errors/ErrorMessage.tsx +50 -50
- package/src/lib/errors/RouterError.tsx +18 -18
- package/src/lib/errors/ServerError.tsx +5 -5
- package/src/lib/errors/TopLevelError.tsx +6 -6
- package/src/lib/hooks/index.ts +13 -13
- package/src/lib/hooks/useEvent.ts +41 -41
- package/src/lib/hooks/useHighlighter.ts +15 -15
- package/src/lib/hooks/useHotkey.ts +70 -70
- package/src/lib/icons.ts +2 -2
- package/src/lib/navigation/applyRules.ts +127 -127
- package/src/lib/navigation/pathMatcher.ts +81 -81
- package/src/lib/oas/graphql/circular.ts +69 -69
- package/src/lib/oas/graphql/constants.ts +2 -2
- package/src/lib/oas/graphql/index.ts +791 -791
- package/src/lib/oas/parser/dereference/index.ts +84 -84
- package/src/lib/oas/parser/dereference/resolveRef.ts +32 -32
- package/src/lib/oas/parser/index.ts +129 -129
- package/src/lib/oas/parser/schemas/v3.0.json +1489 -1489
- package/src/lib/oas/parser/schemas/v3.1.json +1298 -1298
- package/src/lib/oas/parser/upgrade/index.ts +174 -174
- package/src/lib/plugins/api-catalog/Catalog.tsx +80 -80
- package/src/lib/plugins/api-catalog/index.tsx +119 -119
- package/src/lib/plugins/api-keys/CreateApiKey.tsx +128 -128
- package/src/lib/plugins/api-keys/CreateApiKeyDialog.tsx +49 -49
- package/src/lib/plugins/api-keys/ProtectedRoute.tsx +33 -33
- package/src/lib/plugins/api-keys/SettingsApiKeys.tsx +72 -72
- package/src/lib/plugins/api-keys/index.tsx +274 -274
- package/src/lib/plugins/api-keys/settings/ApiKeyItem.tsx +342 -342
- package/src/lib/plugins/api-keys/settings/ApiKeyList.tsx +67 -67
- package/src/lib/plugins/api-keys/settings/RevealApiKey.tsx +127 -127
- package/src/lib/plugins/custom-pages/index.tsx +32 -32
- package/src/lib/plugins/markdown/MdxPage.tsx +260 -260
- package/src/lib/plugins/markdown/assets/ChatGPTLogo.tsx +11 -11
- package/src/lib/plugins/markdown/assets/ClaudeLogo.tsx +19 -19
- package/src/lib/plugins/markdown/index.tsx +82 -82
- package/src/lib/plugins/openapi/ApiHeader.tsx +96 -96
- package/src/lib/plugins/openapi/CollapsibleCode.tsx +88 -88
- package/src/lib/plugins/openapi/ColorizedParam.tsx +115 -115
- package/src/lib/plugins/openapi/DownloadSchemaButton.tsx +94 -94
- package/src/lib/plugins/openapi/Endpoint.tsx +85 -85
- package/src/lib/plugins/openapi/GeneratedExampleSidecarBox.tsx +52 -52
- package/src/lib/plugins/openapi/MCPEndpoint.tsx +273 -273
- package/src/lib/plugins/openapi/OasProvider.tsx +74 -74
- package/src/lib/plugins/openapi/OperationList.tsx +275 -275
- package/src/lib/plugins/openapi/OperationListItem.tsx +183 -183
- package/src/lib/plugins/openapi/ParamInfos.tsx +96 -96
- package/src/lib/plugins/openapi/ParameterList.tsx +53 -53
- package/src/lib/plugins/openapi/ParameterListItem.tsx +161 -161
- package/src/lib/plugins/openapi/PlaygroundDialogWrapper.tsx +67 -67
- package/src/lib/plugins/openapi/RequestBodySidecarBox.tsx +63 -63
- package/src/lib/plugins/openapi/ResponsesSidecarBox.tsx +120 -120
- package/src/lib/plugins/openapi/SchemaInfo.tsx +344 -344
- package/src/lib/plugins/openapi/SchemaList.tsx +113 -113
- package/src/lib/plugins/openapi/Sidecar.tsx +314 -314
- package/src/lib/plugins/openapi/SidecarBox.tsx +57 -57
- package/src/lib/plugins/openapi/SidecarExamples.tsx +177 -177
- package/src/lib/plugins/openapi/SimpleSelect.tsx +46 -46
- package/src/lib/plugins/openapi/client/GraphQLClient.tsx +73 -73
- package/src/lib/plugins/openapi/client/GraphQLContext.tsx +16 -16
- package/src/lib/plugins/openapi/client/createServer.ts +35 -35
- package/src/lib/plugins/openapi/client/useCreateQuery.ts +44 -44
- package/src/lib/plugins/openapi/components/ConstValue.tsx +24 -24
- package/src/lib/plugins/openapi/components/EnumValues.tsx +58 -58
- package/src/lib/plugins/openapi/components/NonHighlightedCode.tsx +22 -22
- package/src/lib/plugins/openapi/components/ResponseContent.tsx +117 -117
- package/src/lib/plugins/openapi/components/SelectOnClick.tsx +29 -29
- package/src/lib/plugins/openapi/context.tsx +16 -16
- package/src/lib/plugins/openapi/graphql/fragment-masking.ts +104 -104
- package/src/lib/plugins/openapi/graphql/gql.ts +95 -95
- package/src/lib/plugins/openapi/graphql/graphql.ts +835 -835
- package/src/lib/plugins/openapi/graphql/index.ts +2 -2
- package/src/lib/plugins/openapi/index.tsx +221 -221
- package/src/lib/plugins/openapi/interfaces.ts +100 -100
- package/src/lib/plugins/openapi/playground/BodyPanel.tsx +274 -274
- package/src/lib/plugins/openapi/playground/CollapsibleHeader.tsx +51 -51
- package/src/lib/plugins/openapi/playground/ExamplesDropdown.tsx +60 -60
- package/src/lib/plugins/openapi/playground/Headers.tsx +194 -194
- package/src/lib/plugins/openapi/playground/IdentityDialog.tsx +75 -75
- package/src/lib/plugins/openapi/playground/IdentitySelector.tsx +38 -38
- package/src/lib/plugins/openapi/playground/ParamsGrid.tsx +45 -45
- package/src/lib/plugins/openapi/playground/PathParams.tsx +53 -53
- package/src/lib/plugins/openapi/playground/Playground.tsx +602 -602
- package/src/lib/plugins/openapi/playground/PlaygroundDialog.tsx +62 -62
- package/src/lib/plugins/openapi/playground/QueryParams.tsx +151 -151
- package/src/lib/plugins/openapi/playground/RequestLoginDialog.tsx +70 -70
- package/src/lib/plugins/openapi/playground/Spinner.tsx +87 -87
- package/src/lib/plugins/openapi/playground/createUrl.ts +25 -25
- package/src/lib/plugins/openapi/playground/fileUtils.ts +36 -36
- package/src/lib/plugins/openapi/playground/rememberedIdentity.ts +26 -26
- package/src/lib/plugins/openapi/playground/request-panel/MultipartField.tsx +91 -91
- package/src/lib/plugins/openapi/playground/request-panel/UrlPath.tsx +31 -31
- package/src/lib/plugins/openapi/playground/request-panel/UrlQueryParams.tsx +29 -29
- package/src/lib/plugins/openapi/playground/request-panel/useKeyValueFieldManager.ts +340 -340
- package/src/lib/plugins/openapi/playground/result-panel/AudioPlayer.tsx +50 -50
- package/src/lib/plugins/openapi/playground/result-panel/ResponseStatusBar.tsx +117 -117
- package/src/lib/plugins/openapi/playground/result-panel/ResponseTab.tsx +355 -355
- package/src/lib/plugins/openapi/playground/result-panel/ResultPanel.tsx +106 -106
- package/src/lib/plugins/openapi/playground/result-panel/convertToTypes.ts +36 -36
- package/src/lib/plugins/openapi/playground/serializeQueryParams.ts +125 -125
- package/src/lib/plugins/openapi/playground/useRememberSkipLoginDialog.tsx +23 -23
- package/src/lib/plugins/openapi/processors/injectApitogoGatewayServer.ts +36 -36
- package/src/lib/plugins/openapi/processors/removeExtensions.ts +29 -29
- package/src/lib/plugins/openapi/processors/removeParameters.ts +103 -103
- package/src/lib/plugins/openapi/processors/removePaths.ts +57 -57
- package/src/lib/plugins/openapi/processors/traverse.ts +1 -1
- package/src/lib/plugins/openapi/schema/SchemaExampleAndDefault.tsx +39 -39
- package/src/lib/plugins/openapi/schema/SchemaPropertyItem.tsx +189 -189
- package/src/lib/plugins/openapi/schema/SchemaView.tsx +299 -299
- package/src/lib/plugins/openapi/schema/UnionView.tsx +132 -132
- package/src/lib/plugins/openapi/schema/union-helpers.ts +123 -123
- package/src/lib/plugins/openapi/schema/utils.ts +49 -49
- package/src/lib/plugins/openapi/state.ts +36 -36
- package/src/lib/plugins/openapi/util/buildTagCategories.ts +54 -54
- package/src/lib/plugins/openapi/util/createHttpSnippet.ts +135 -135
- package/src/lib/plugins/openapi/util/createNavigationCategory.tsx +39 -39
- package/src/lib/plugins/openapi/util/generateSchemaExample.ts +191 -191
- package/src/lib/plugins/openapi/util/getRoutes.tsx +265 -265
- package/src/lib/plugins/openapi/util/methodColorMap.tsx +11 -11
- package/src/lib/plugins/openapi/util/methodToColor.ts +27 -27
- package/src/lib/plugins/openapi/util/sanitizeMarkdownForMetatag.tsx +32 -32
- package/src/lib/plugins/openapi/util/useWarmupSchema.ts +23 -23
- package/src/lib/plugins/search-inkeep/index.tsx +108 -108
- package/src/lib/plugins/search-inkeep/inkeep.ts +24 -24
- package/src/lib/plugins/search-pagefind/IndexingDialog.tsx +162 -162
- package/src/lib/plugins/search-pagefind/PagefindSearch.tsx +202 -202
- package/src/lib/plugins/search-pagefind/ResultList.tsx +122 -122
- package/src/lib/plugins/search-pagefind/get-results.tsx +74 -74
- package/src/lib/plugins/search-pagefind/index.tsx +21 -21
- package/src/lib/plugins/search-pagefind/types.ts +118 -118
- package/src/lib/shiki-constants.ts +24 -24
- package/src/lib/shiki.ts +179 -179
- package/src/lib/testing/index.tsx +147 -147
- package/src/lib/ui/Accordion.tsx +55 -55
- package/src/lib/ui/ActionButton.tsx +21 -21
- package/src/lib/ui/Alert.tsx +84 -84
- package/src/lib/ui/AlertDialog.tsx +195 -195
- package/src/lib/ui/AspectRatio.tsx +5 -5
- package/src/lib/ui/Badge.tsx +48 -48
- package/src/lib/ui/Breadcrumb.tsx +115 -115
- package/src/lib/ui/Button.tsx +65 -65
- package/src/lib/ui/ButtonGroup.tsx +80 -80
- package/src/lib/ui/Callout.tsx +113 -113
- package/src/lib/ui/Card.tsx +95 -95
- package/src/lib/ui/Carousel.tsx +259 -259
- package/src/lib/ui/Checkbox.tsx +28 -28
- package/src/lib/ui/CodeBlock.tsx +92 -92
- package/src/lib/ui/CodeTabPanel.tsx +13 -13
- package/src/lib/ui/CodeTabs.tsx +161 -161
- package/src/lib/ui/Collapsible.tsx +31 -31
- package/src/lib/ui/Command.tsx +191 -191
- package/src/lib/ui/Dialog.tsx +138 -138
- package/src/lib/ui/DismissibleAlert.tsx +74 -74
- package/src/lib/ui/Drawer.tsx +121 -121
- package/src/lib/ui/DropdownMenu.tsx +254 -254
- package/src/lib/ui/EmbeddedCodeBlock.tsx +99 -99
- package/src/lib/ui/Form.tsx +176 -176
- package/src/lib/ui/Frame.tsx +81 -81
- package/src/lib/ui/HoverCard.tsx +26 -26
- package/src/lib/ui/Input.tsx +23 -23
- package/src/lib/ui/InputGroup.tsx +168 -168
- package/src/lib/ui/Item.tsx +213 -213
- package/src/lib/ui/Kbd.tsx +28 -28
- package/src/lib/ui/Label.tsx +23 -23
- package/src/lib/ui/NativeSelect.tsx +48 -48
- package/src/lib/ui/NavigationMenu.tsx +169 -169
- package/src/lib/ui/Pagination.tsx +115 -115
- package/src/lib/ui/Popover.tsx +28 -28
- package/src/lib/ui/Progress.tsx +25 -25
- package/src/lib/ui/RadioGroup.tsx +41 -41
- package/src/lib/ui/ReactComponentDoc.tsx +68 -68
- package/src/lib/ui/ScrollArea.tsx +45 -45
- package/src/lib/ui/Secret.tsx +133 -133
- package/src/lib/ui/Select.tsx +184 -184
- package/src/lib/ui/Separator.tsx +25 -25
- package/src/lib/ui/Skeleton.tsx +15 -15
- package/src/lib/ui/Slider.tsx +25 -25
- package/src/lib/ui/Stepper.tsx +8 -8
- package/src/lib/ui/Switch.tsx +26 -26
- package/src/lib/ui/SyntaxHighlight.tsx +54 -54
- package/src/lib/ui/Tabs.tsx +52 -52
- package/src/lib/ui/Textarea.tsx +22 -22
- package/src/lib/ui/Toggle.tsx +42 -42
- package/src/lib/ui/ToggleGroup.tsx +58 -58
- package/src/lib/ui/Tooltip.tsx +65 -65
- package/src/lib/ui/Value.tsx +42 -42
- package/src/lib/ui/util.tsx +8 -8
- package/src/lib/util/MdxComponents.tsx +150 -150
- package/src/lib/util/cn.ts +6 -6
- package/src/lib/util/createVariantComponent.tsx +61 -61
- package/src/lib/util/ensureArray.ts +3 -3
- package/src/lib/util/flattenAllOf.ts +72 -72
- package/src/lib/util/flattenAllOfProcessor.ts +74 -74
- package/src/lib/util/hastToJsx.tsx +67 -67
- package/src/lib/util/humanFileSize.ts +15 -15
- package/src/lib/util/invariant.ts +52 -52
- package/src/lib/util/joinUrl.ts +60 -60
- package/src/lib/util/logInit.ts +9 -9
- package/src/lib/util/objectEntries.ts +5 -5
- package/src/lib/util/os.ts +18 -18
- package/src/lib/util/pastellize.ts +25 -25
- package/src/lib/util/problemJson.ts +63 -63
- package/src/lib/util/readFrontmatter.ts +14 -14
- package/src/lib/util/renderIf.ts +4 -4
- package/src/lib/util/scrollIntoViewIfNeeded.ts +18 -18
- package/src/lib/util/slugify.ts +21 -21
- package/src/lib/util/syncZustandState.ts +22 -22
- package/src/lib/util/traverse.ts +70 -70
- package/src/lib/util/types.ts +7 -7
- package/src/lib/util/url.ts +18 -18
- package/src/lib/util/useCopyToClipboard.ts +17 -17
- package/src/lib/util/useExposedProps.tsx +27 -27
- package/src/lib/util/useIsomorphicLayoutEffect.ts +6 -6
- package/src/lib/util/useLatest.ts +18 -18
- package/src/lib/util/useOnScreen.ts +34 -34
- package/src/lib/util/useScrollToAnchor.ts +75 -75
- package/src/lib/util/useScrollToTop.ts +18 -18
- package/src/types.d.ts +66 -66
- package/src/vite/api/SchemaManager.ts +371 -371
- package/src/vite/api/schema-codegen.ts +163 -163
- package/src/vite/build.ts +237 -237
- package/src/vite/config.ts +273 -273
- package/src/vite/css/collect.ts +42 -42
- package/src/vite/css/plugin.ts +105 -105
- package/src/vite/debug.ts +18 -18
- package/src/vite/dev-portal-env.ts +29 -29
- package/src/vite/dev-server.ts +294 -294
- package/src/vite/html.ts +52 -52
- package/src/vite/index.ts +2 -2
- package/src/vite/llms.ts +99 -99
- package/src/vite/mdx/rehype-extract-toc-with-jsx-export.ts +19 -19
- package/src/vite/mdx/rehype-extract-toc-with-jsx.ts +70 -70
- package/src/vite/mdx/remark-code-tabs.ts +47 -47
- package/src/vite/mdx/remark-inject-filepath.ts +13 -13
- package/src/vite/mdx/remark-last-modified.ts +103 -103
- package/src/vite/mdx/remark-link-rewrite.ts +23 -23
- package/src/vite/mdx/remark-normalize-image-url.ts +15 -15
- package/src/vite/mdx/remark-static-generation.ts +149 -149
- package/src/vite/mdx/utils.ts +32 -32
- package/src/vite/output.ts +261 -261
- package/src/vite/package-root.ts +12 -12
- package/src/vite/pagefind-dev-index.ts +100 -100
- package/src/vite/pagefind-dev-stub.ts +17 -17
- package/src/vite/plugin-api-keys.ts +55 -55
- package/src/vite/plugin-api.ts +328 -328
- package/src/vite/plugin-auth.ts +41 -41
- package/src/vite/plugin-component.ts +45 -45
- package/src/vite/plugin-config-reload.ts +47 -47
- package/src/vite/plugin-config.ts +131 -131
- package/src/vite/plugin-custom-pages.ts +36 -36
- package/src/vite/plugin-doc-metadata.ts +45 -45
- package/src/vite/plugin-docs.ts +205 -205
- package/src/vite/plugin-local-manifest.ts +100 -100
- package/src/vite/plugin-markdown-export.ts +214 -214
- package/src/vite/plugin-mdx.ts +149 -149
- package/src/vite/plugin-metadata.ts +30 -30
- package/src/vite/plugin-modules.ts +188 -188
- package/src/vite/plugin-navigation.ts +106 -106
- package/src/vite/plugin-offroot-resolve.ts +67 -67
- package/src/vite/plugin-search.ts +59 -59
- package/src/vite/plugin-shiki-register.ts +97 -97
- package/src/vite/plugin-theme.ts +458 -458
- package/src/vite/plugin.ts +48 -48
- package/src/vite/prerender/prerender.ts +242 -242
- package/src/vite/prerender/utils.ts +76 -76
- package/src/vite/prerender/worker.ts +118 -118
- package/src/vite/reporter.ts +34 -34
- package/src/vite/shadcn-registry.ts +48 -48
- package/src/vite/sitemap.ts +78 -78
- package/src/vite/ssr-templates/cloudflare.ts +19 -19
- package/src/vite/ssr-templates/node.ts +25 -25
- package/src/vite/ssr-templates/vercel.ts +22 -22
- package/src/vite/watch-ignore.ts +70 -70
- package/src/vite/zuplo.ts +30 -30
- package/src/vite-env.d.ts +6 -6
- package/src/zuplo/enrich-with-zuplo-mcp.ts +168 -168
- package/src/zuplo/enrich-with-zuplo.ts +256 -256
- package/src/zuplo/policy-types.ts +46 -46
- package/src/zuplo/with-zuplo-processors.ts +36 -36
- package/src/zuplo/with-zuplo.ts +14 -14
- package/dist/declarations/cli/platform/client/deploy-server.d.ts +0 -16
package/src/config/v2/rules.ts
CHANGED
|
@@ -1,714 +1,714 @@
|
|
|
1
|
-
import { readFile, stat } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { glob } from "glob";
|
|
4
|
-
import { parse as parseYaml } from "yaml";
|
|
5
|
-
import type { ConfigIssue } from "./errors.js";
|
|
6
|
-
import type { ResolvedConfigV2 } from "./resolve.js";
|
|
7
|
-
import type { ConfigV2 } from "./schema.js";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* The rules of `apitogo check` that need no network (Reference, section 2). Online rules (provider capabilities,
|
|
11
|
-
* origin reachability, OIDC discovery, tier limits) are not here: they run against the platform in a later phase.
|
|
12
|
-
*
|
|
13
|
-
* Every problem carries a stable code, the config path and a hint. All problems are collected, never just the first.
|
|
14
|
-
* Services with `enabled: false` are ignored, as the Tenant Service Model says.
|
|
15
|
-
*/
|
|
16
|
-
export type CheckReport = {
|
|
17
|
-
errors: ConfigIssue[];
|
|
18
|
-
warnings: ConfigIssue[];
|
|
19
|
-
/** Rules that did not run, so a clean report is never mistaken for "everything was checked". */
|
|
20
|
-
skipped: string[];
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
export type CheckContext = {
|
|
24
|
-
/** Names of the project's secrets, when they are known. Leave undefined when they cannot be listed. */
|
|
25
|
-
knownSecrets?: ReadonlySet<string>;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
type Collector = {
|
|
29
|
-
error: (issue: ConfigIssue) => void;
|
|
30
|
-
warn: (issue: ConfigIssue) => void;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const SPEC_MAX_BYTES = 5 * 1024 * 1024;
|
|
34
|
-
const SECRET_REFERENCE = /^secret:([A-Za-z_][A-Za-z0-9_]*)$/;
|
|
35
|
-
|
|
36
|
-
async function isFile(target: string): Promise<boolean> {
|
|
37
|
-
return (await stat(target).catch(() => undefined))?.isFile() === true;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function isDirectory(target: string): Promise<boolean> {
|
|
41
|
-
return (await stat(target).catch(() => undefined))?.isDirectory() === true;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Every `secret:NAME` reference in the config, with the path it was found at. */
|
|
45
|
-
export function collectSecretRefs(
|
|
46
|
-
config: unknown,
|
|
47
|
-
): { name: string; path: string }[] {
|
|
48
|
-
const found: { name: string; path: string }[] = [];
|
|
49
|
-
const walk = (value: unknown, at: string) => {
|
|
50
|
-
if (typeof value === "string") {
|
|
51
|
-
const match = SECRET_REFERENCE.exec(value);
|
|
52
|
-
if (match?.[1]) found.push({ name: match[1], path: at });
|
|
53
|
-
} else if (Array.isArray(value)) {
|
|
54
|
-
value.forEach((item, index) => walk(item, `${at}[${index}]`));
|
|
55
|
-
} else if (typeof value === "object" && value !== null) {
|
|
56
|
-
for (const [key, item] of Object.entries(value))
|
|
57
|
-
walk(item, at === "" ? key : `${at}.${key}`);
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
walk(config, "");
|
|
61
|
-
return found;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// --- dependencies between services (2.1) ----------------------------------------------------------------------------
|
|
65
|
-
|
|
66
|
-
function dependencyRules(
|
|
67
|
-
config: ConfigV2,
|
|
68
|
-
resolved: ResolvedConfigV2,
|
|
69
|
-
out: Collector,
|
|
70
|
-
) {
|
|
71
|
-
const gatewayOn = resolved.gateway?.enabled === true;
|
|
72
|
-
const identityOn = resolved.identity?.enabled === true;
|
|
73
|
-
const oidcGateway = gatewayOn && resolved.gateway?.auth?.mode === "oidc";
|
|
74
|
-
const hasIdentitySource = identityOn || oidcGateway;
|
|
75
|
-
|
|
76
|
-
if (resolved.plans?.enabled) {
|
|
77
|
-
if (!gatewayOn) {
|
|
78
|
-
out.error({
|
|
79
|
-
code: "PLANS_GATEWAY_REQUIRED",
|
|
80
|
-
path: "plans",
|
|
81
|
-
message:
|
|
82
|
-
"plans needs the gateway service: entitlements and API keys are enforced there.",
|
|
83
|
-
hint: "Enable it with `apitogo add gateway`.",
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
if (!hasIdentitySource) {
|
|
87
|
-
out.error({
|
|
88
|
-
code: "PLANS_IDENTITY_REQUIRED",
|
|
89
|
-
path: "plans",
|
|
90
|
-
message: "plans needs an identity source to know who subscribes.",
|
|
91
|
-
hint: "Enable identity (apitogo add identity) or set gateway.auth.mode to oidc.",
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const requireAuth = config.portal?.requireAuth;
|
|
97
|
-
if (
|
|
98
|
-
resolved.portal?.enabled &&
|
|
99
|
-
requireAuth !== undefined &&
|
|
100
|
-
requireAuth !== false &&
|
|
101
|
-
!hasIdentitySource
|
|
102
|
-
) {
|
|
103
|
-
out.error({
|
|
104
|
-
code: "PORTAL_CAPABILITY_MISSING",
|
|
105
|
-
path: "portal.requireAuth",
|
|
106
|
-
message:
|
|
107
|
-
"portal.requireAuth needs an identity source. Enable identity (apitogo add identity) or set gateway.auth.mode to oidc.",
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Only the dependency rules (2.1), for commands that edit the config before any file exists. */
|
|
113
|
-
export function checkDependencies(
|
|
114
|
-
config: ConfigV2,
|
|
115
|
-
resolved: ResolvedConfigV2,
|
|
116
|
-
): ConfigIssue[] {
|
|
117
|
-
const issues: ConfigIssue[] = [];
|
|
118
|
-
dependencyRules(config, resolved, {
|
|
119
|
-
error: (issue) => void issues.push(issue),
|
|
120
|
-
warn: () => undefined,
|
|
121
|
-
});
|
|
122
|
-
return issues;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// --- portal (2.3) ---------------------------------------------------------------------------------------------------
|
|
126
|
-
|
|
127
|
-
async function portalRules(
|
|
128
|
-
config: ConfigV2,
|
|
129
|
-
resolved: ResolvedConfigV2,
|
|
130
|
-
out: Collector,
|
|
131
|
-
) {
|
|
132
|
-
const portal = resolved.portal;
|
|
133
|
-
if (!portal?.enabled) return;
|
|
134
|
-
|
|
135
|
-
if (!portal.docs.enabled && !portal.console.enabled) {
|
|
136
|
-
out.error({
|
|
137
|
-
code: "PORTAL_NOTHING_ENABLED",
|
|
138
|
-
path: "portal",
|
|
139
|
-
message:
|
|
140
|
-
"portal has docs and console both disabled, so there is nothing to build.",
|
|
141
|
-
hint: "Enable portal.docs or portal.console, or switch the portal off with portal.enabled: false.",
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (portal.docs.enabled) {
|
|
146
|
-
if (portal.docs.provider === "external") {
|
|
147
|
-
if (!config.portal?.docs?.url) {
|
|
148
|
-
out.error({
|
|
149
|
-
code: "PORTAL_DOCS_URL_INVALID",
|
|
150
|
-
path: "portal.docs.url",
|
|
151
|
-
message:
|
|
152
|
-
"portal.docs.url is required when docs.provider is external.",
|
|
153
|
-
hint: 'For example: "url": "https://docs.acme.com".',
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
if (config.portal?.docs?.content !== undefined) {
|
|
157
|
-
out.error({
|
|
158
|
-
code: "PORTAL_CONFIG_INVALID",
|
|
159
|
-
path: "portal.docs.content",
|
|
160
|
-
message:
|
|
161
|
-
"portal.docs.content is not allowed when docs.provider is external: the docs live in the external tool.",
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
if (config.portal?.search !== undefined) {
|
|
165
|
-
out.error({
|
|
166
|
-
code: "PORTAL_CONFIG_INVALID",
|
|
167
|
-
path: "portal.search",
|
|
168
|
-
message:
|
|
169
|
-
"portal.search is not allowed when docs.provider is external: the external tool does the searching.",
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
if (resolved.gateway?.enabled !== true) {
|
|
173
|
-
out.error({
|
|
174
|
-
code: "PORTAL_EXTERNAL_DOCS_NO_SPEC",
|
|
175
|
-
path: "portal.docs",
|
|
176
|
-
message:
|
|
177
|
-
"docs.provider external needs an OpenAPI source, but the gateway is not enabled.",
|
|
178
|
-
hint: "Enable the gateway with specs: its OpenAPI export is what the external docs tool reads.",
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
} else {
|
|
182
|
-
if (config.portal?.docs?.url !== undefined) {
|
|
183
|
-
out.error({
|
|
184
|
-
code: "PORTAL_CONFIG_INVALID",
|
|
185
|
-
path: "portal.docs.url",
|
|
186
|
-
message:
|
|
187
|
-
'portal.docs.url is only used when docs.provider is "external".',
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
await docsContentRule(config, portal.docs.content, resolved, out);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (
|
|
195
|
-
portal.console.enabled &&
|
|
196
|
-
!portal.docs.enabled &&
|
|
197
|
-
!resolved.identity?.enabled &&
|
|
198
|
-
!resolved.plans?.enabled
|
|
199
|
-
) {
|
|
200
|
-
out.warn({
|
|
201
|
-
code: "PORTAL_CONSOLE_EMPTY",
|
|
202
|
-
path: "portal.console",
|
|
203
|
-
message:
|
|
204
|
-
"the console is enabled but no service feeds it, so it has no sign-in, pricing, keys or usage pages.",
|
|
205
|
-
hint: "Enable identity or plans, or enable portal.docs.",
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (await isFile(portal.theme)) {
|
|
210
|
-
try {
|
|
211
|
-
JSON.parse(await readFile(portal.theme, "utf8"));
|
|
212
|
-
} catch (error) {
|
|
213
|
-
out.error({
|
|
214
|
-
code: "PORTAL_THEME_INVALID",
|
|
215
|
-
path: "portal.theme",
|
|
216
|
-
message: `the theme file is not valid JSON (${error instanceof Error ? error.message : String(error)}).`,
|
|
217
|
-
hint: path.relative(resolved.configDir, portal.theme),
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
async function docsContentRule(
|
|
224
|
-
config: ConfigV2,
|
|
225
|
-
patterns: readonly string[],
|
|
226
|
-
resolved: ResolvedConfigV2,
|
|
227
|
-
out: Collector,
|
|
228
|
-
) {
|
|
229
|
-
const include = patterns.filter((pattern) => !pattern.startsWith("!"));
|
|
230
|
-
const ignore = patterns
|
|
231
|
-
.filter((pattern) => pattern.startsWith("!"))
|
|
232
|
-
.map((pattern) => pattern.slice(1));
|
|
233
|
-
const matches =
|
|
234
|
-
include.length > 0
|
|
235
|
-
? await glob(include, { nodir: true, ignore, absolute: true })
|
|
236
|
-
: [];
|
|
237
|
-
if (matches.length > 0) return;
|
|
238
|
-
|
|
239
|
-
const shown = config.portal?.docs?.content ?? [
|
|
240
|
-
path.relative(resolved.configDir, patterns[0] ?? ""),
|
|
241
|
-
];
|
|
242
|
-
out.warn({
|
|
243
|
-
code: "PORTAL_DOCS_EMPTY",
|
|
244
|
-
path: "portal.docs",
|
|
245
|
-
message: `portal.docs matched 0 files (${shown.join(", ")}). The site will only contain the API reference.`,
|
|
246
|
-
hint: "Add .md or .mdx files, or point docs.content at your existing docs.",
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// --- gateway (2.4) --------------------------------------------------------------------------------------------------
|
|
251
|
-
|
|
252
|
-
function isIpLiteral(hostname: string): boolean {
|
|
253
|
-
return /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.startsWith("[");
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/** The credential the gateway sends to the origin: a header needs a name and a secret; mtls is not available yet. */
|
|
257
|
-
function originAuthRules(
|
|
258
|
-
origin: NonNullable<ResolvedConfigV2["gateway"]>["origin"],
|
|
259
|
-
out: Collector,
|
|
260
|
-
) {
|
|
261
|
-
const auth = origin.auth;
|
|
262
|
-
if (!auth) return;
|
|
263
|
-
const base = "gateway.origin.auth";
|
|
264
|
-
if (auth.type === "header") {
|
|
265
|
-
if (!auth.name)
|
|
266
|
-
out.error({
|
|
267
|
-
code: "GATEWAY_ORIGIN_INCOMPLETE",
|
|
268
|
-
path: `${base}.name`,
|
|
269
|
-
message: "origin.auth.type is header, so origin.auth.name is required.",
|
|
270
|
-
hint: 'For example: "X-Origin-Key".',
|
|
271
|
-
});
|
|
272
|
-
if (!auth.value)
|
|
273
|
-
out.error({
|
|
274
|
-
code: "GATEWAY_ORIGIN_INCOMPLETE",
|
|
275
|
-
path: `${base}.value`,
|
|
276
|
-
message:
|
|
277
|
-
"origin.auth.type is header, so origin.auth.value is required.",
|
|
278
|
-
hint: 'A secret reference, for example "secret:ORIGIN_KEY". Store it with `apitogo secrets set ORIGIN_KEY`.',
|
|
279
|
-
});
|
|
280
|
-
} else if (auth.type === "none" && (auth.name || auth.value)) {
|
|
281
|
-
out.error({
|
|
282
|
-
code: "GATEWAY_ORIGIN_INVALID",
|
|
283
|
-
path: base,
|
|
284
|
-
message: "origin.auth.type is none, but a name or value is set.",
|
|
285
|
-
hint: 'Remove them, or set the type to "header".',
|
|
286
|
-
});
|
|
287
|
-
} else if (auth.type === "mtls") {
|
|
288
|
-
out.error({
|
|
289
|
-
code: "GATEWAY_CAPABILITY_UNSUPPORTED",
|
|
290
|
-
path: `${base}.type`,
|
|
291
|
-
message:
|
|
292
|
-
"Client certificates (mtls) for the origin are not available yet.",
|
|
293
|
-
hint: 'Use type "header" with a secret.',
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
async function gatewayRules(resolved: ResolvedConfigV2, out: Collector) {
|
|
299
|
-
const gateway = resolved.gateway;
|
|
300
|
-
if (!gateway?.enabled) return;
|
|
301
|
-
const origin = gateway.origin;
|
|
302
|
-
|
|
303
|
-
(gateway.policies.cors ?? []).forEach((site, index) => {
|
|
304
|
-
const { pathname, search, hash } = new URL(site);
|
|
305
|
-
if (pathname === "/" && !search && !hash && !site.endsWith("/")) return;
|
|
306
|
-
out.error({
|
|
307
|
-
code: "GATEWAY_CORS_ORIGIN_INVALID",
|
|
308
|
-
path: `gateway.policies.cors[${index}]`,
|
|
309
|
-
message: `"${site}" is not a browser origin: it has a path, a query or a trailing slash.`,
|
|
310
|
-
hint: `Use only scheme and host, for example "${new URL(site).origin}".`,
|
|
311
|
-
});
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
if (origin.mode === "managed") {
|
|
315
|
-
for (const field of ["source", "runtime", "runtimeVersion"] as const) {
|
|
316
|
-
if (!origin[field]) {
|
|
317
|
-
out.error({
|
|
318
|
-
code: "GATEWAY_ORIGIN_INCOMPLETE",
|
|
319
|
-
path: `gateway.origin.${field}`,
|
|
320
|
-
message: `gateway.origin.${field} is required when origin.mode is managed.`,
|
|
321
|
-
hint:
|
|
322
|
-
field === "runtimeVersion"
|
|
323
|
-
? 'For example: "NODE_22_LTS". The platform publishes the allowed values.'
|
|
324
|
-
: undefined,
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
if (origin.source) {
|
|
329
|
-
const inside = path.relative(resolved.configDir, origin.source);
|
|
330
|
-
if (inside.startsWith("..") || path.isAbsolute(inside)) {
|
|
331
|
-
out.error({
|
|
332
|
-
code: "GATEWAY_ORIGIN_INVALID",
|
|
333
|
-
path: "gateway.origin.source",
|
|
334
|
-
message:
|
|
335
|
-
"gateway.origin.source must stay inside the project directory.",
|
|
336
|
-
});
|
|
337
|
-
} else if (!(await isDirectory(origin.source))) {
|
|
338
|
-
out.error({
|
|
339
|
-
code: "GATEWAY_ORIGIN_INVALID",
|
|
340
|
-
path: "gateway.origin.source",
|
|
341
|
-
message: `gateway.origin.source does not exist: ${inside === "" ? "." : inside}.`,
|
|
342
|
-
hint: "Point it at the folder that holds your backend code.",
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
if (origin.url !== undefined) {
|
|
347
|
-
out.error({
|
|
348
|
-
code: "GATEWAY_ORIGIN_INVALID",
|
|
349
|
-
path: "gateway.origin.url",
|
|
350
|
-
message: "gateway.origin.url is only used when origin.mode is proxy.",
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
} else {
|
|
354
|
-
if (!origin.url) {
|
|
355
|
-
out.error({
|
|
356
|
-
code: "GATEWAY_ORIGIN_INVALID_URL",
|
|
357
|
-
path: "gateway.origin.url",
|
|
358
|
-
message: "gateway.origin.url is required when origin.mode is proxy.",
|
|
359
|
-
hint: 'For example: "https://api.acme.com".',
|
|
360
|
-
});
|
|
361
|
-
} else {
|
|
362
|
-
const url = new URL(origin.url);
|
|
363
|
-
if (url.username !== "" || url.password !== "") {
|
|
364
|
-
out.error({
|
|
365
|
-
code: "GATEWAY_ORIGIN_INVALID_URL",
|
|
366
|
-
path: "gateway.origin.url",
|
|
367
|
-
message: "gateway.origin.url must not contain credentials.",
|
|
368
|
-
hint: "Use an origin secret header instead.",
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
if (isIpLiteral(url.hostname)) {
|
|
372
|
-
out.error({
|
|
373
|
-
code: "GATEWAY_ORIGIN_INVALID_URL",
|
|
374
|
-
path: "gateway.origin.url",
|
|
375
|
-
message:
|
|
376
|
-
"gateway.origin.url must use a host name, not an IP address.",
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
for (const field of [
|
|
381
|
-
"source",
|
|
382
|
-
"runtime",
|
|
383
|
-
"runtimeVersion",
|
|
384
|
-
"exclude",
|
|
385
|
-
] as const) {
|
|
386
|
-
if (origin[field] !== undefined) {
|
|
387
|
-
out.error({
|
|
388
|
-
code: "GATEWAY_ORIGIN_INVALID",
|
|
389
|
-
path: `gateway.origin.${field}`,
|
|
390
|
-
message: `gateway.origin.${field} is only used when origin.mode is managed.`,
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
originAuthRules(origin, out);
|
|
397
|
-
if (gateway.specs.length > 1) {
|
|
398
|
-
out.warn({
|
|
399
|
-
code: "GATEWAY_SPEC_INVALID",
|
|
400
|
-
path: "gateway.specs",
|
|
401
|
-
message:
|
|
402
|
-
"gateway.specs lists several files, but the platform takes one OpenAPI file per gateway, so deploy will refuse it.",
|
|
403
|
-
hint: "Merge them into one file.",
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
for (const [index, spec] of gateway.specs.entries())
|
|
408
|
-
await specRule(spec, index, resolved, out);
|
|
409
|
-
|
|
410
|
-
authRule(resolved, out);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
async function specRule(
|
|
414
|
-
spec: string,
|
|
415
|
-
index: number,
|
|
416
|
-
resolved: ResolvedConfigV2,
|
|
417
|
-
out: Collector,
|
|
418
|
-
) {
|
|
419
|
-
const at = `gateway.specs[${index}]`;
|
|
420
|
-
const shown = path.relative(resolved.configDir, spec);
|
|
421
|
-
const info = await stat(spec).catch(() => undefined);
|
|
422
|
-
if (!info?.isFile()) {
|
|
423
|
-
out.error({
|
|
424
|
-
code: "GATEWAY_SPEC_INVALID",
|
|
425
|
-
path: at,
|
|
426
|
-
message: `OpenAPI file not found: ${shown}.`,
|
|
427
|
-
});
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
if (info.size > SPEC_MAX_BYTES) {
|
|
431
|
-
out.error({
|
|
432
|
-
code: "GATEWAY_SPEC_INVALID",
|
|
433
|
-
path: at,
|
|
434
|
-
message: `${shown} is ${(info.size / 1024 / 1024).toFixed(1)} MB; the limit is 5 MB.`,
|
|
435
|
-
});
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
438
|
-
let document: unknown;
|
|
439
|
-
try {
|
|
440
|
-
const text = await readFile(spec, "utf8");
|
|
441
|
-
document = spec.toLowerCase().endsWith(".json")
|
|
442
|
-
? JSON.parse(text)
|
|
443
|
-
: parseYaml(text);
|
|
444
|
-
} catch (error) {
|
|
445
|
-
out.error({
|
|
446
|
-
code: "GATEWAY_SPEC_INVALID",
|
|
447
|
-
path: at,
|
|
448
|
-
message: `${shown} cannot be parsed (${error instanceof Error ? error.message.split("\n")[0] : String(error)}).`,
|
|
449
|
-
});
|
|
450
|
-
return;
|
|
451
|
-
}
|
|
452
|
-
const version = (document as { openapi?: unknown } | null)?.openapi;
|
|
453
|
-
if (typeof version !== "string" || !/^3\.[01]\./.test(version)) {
|
|
454
|
-
out.error({
|
|
455
|
-
code: "GATEWAY_SPEC_INVALID",
|
|
456
|
-
path: at,
|
|
457
|
-
message: `${shown} is not an OpenAPI 3.0 or 3.1 document.`,
|
|
458
|
-
hint: 'It needs a top-level "openapi": "3.0.x" or "3.1.x".',
|
|
459
|
-
});
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
function authRule(resolved: ResolvedConfigV2, out: Collector) {
|
|
464
|
-
const auth = resolved.gateway?.auth;
|
|
465
|
-
if (!auth) return;
|
|
466
|
-
|
|
467
|
-
if (auth.mode === "identity") {
|
|
468
|
-
if (!resolved.identity?.enabled) {
|
|
469
|
-
out.error({
|
|
470
|
-
code: "GATEWAY_AUTH_INCOMPLETE",
|
|
471
|
-
path: "gateway.auth.mode",
|
|
472
|
-
message:
|
|
473
|
-
"gateway.auth.mode is identity but the identity service is not enabled.",
|
|
474
|
-
hint: "Enable it with `apitogo add identity`, or use gateway.auth.mode oidc with your own provider.",
|
|
475
|
-
});
|
|
476
|
-
}
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const parts = {
|
|
481
|
-
metadataUrl: auth.metadataUrl ? 1 : 0,
|
|
482
|
-
issuers: auth.issuers?.length ?? 0,
|
|
483
|
-
audiences: auth.audiences?.length ?? 0,
|
|
484
|
-
};
|
|
485
|
-
const present = Object.entries(parts)
|
|
486
|
-
.filter(([, count]) => count > 0)
|
|
487
|
-
.map(([name]) => name);
|
|
488
|
-
const missing = Object.entries(parts)
|
|
489
|
-
.filter(([, count]) => count === 0)
|
|
490
|
-
.map(([name]) => name);
|
|
491
|
-
if (missing.length === 0) return;
|
|
492
|
-
|
|
493
|
-
const blocked = "Token validation would be skipped, so deploy is blocked.";
|
|
494
|
-
if (present.length === 0) {
|
|
495
|
-
out.error({
|
|
496
|
-
code: "GATEWAY_AUTH_INCOMPLETE",
|
|
497
|
-
path: "gateway.auth",
|
|
498
|
-
message: `gateway.auth.mode is oidc but metadataUrl, issuers and audiences are all empty. ${blocked}`,
|
|
499
|
-
});
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
for (const name of missing) {
|
|
503
|
-
out.error({
|
|
504
|
-
code: "GATEWAY_AUTH_INCOMPLETE",
|
|
505
|
-
path: "gateway.auth",
|
|
506
|
-
message: `gateway.auth: ${present[0]} is set but ${name} is empty. ${blocked}`,
|
|
507
|
-
});
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// --- identity (2.5) -------------------------------------------------------------------------------------------------
|
|
512
|
-
|
|
513
|
-
function isLocalHost(hostname: string): boolean {
|
|
514
|
-
return (
|
|
515
|
-
hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
function identityRules(
|
|
520
|
-
config: ConfigV2,
|
|
521
|
-
resolved: ResolvedConfigV2,
|
|
522
|
-
out: Collector,
|
|
523
|
-
) {
|
|
524
|
-
const identity = resolved.identity;
|
|
525
|
-
const raw = config.identity;
|
|
526
|
-
if (!identity?.enabled || !raw) return;
|
|
527
|
-
const invalid = (at: string, message: string, hint?: string) =>
|
|
528
|
-
out.error({ code: "IDENTITY_CONFIG_INVALID", path: at, message, hint });
|
|
529
|
-
|
|
530
|
-
if (identity.provider === "oidc") {
|
|
531
|
-
if (!raw.issuer)
|
|
532
|
-
invalid("identity.issuer", 'identity.provider "oidc" requires "issuer".');
|
|
533
|
-
const consoleOn =
|
|
534
|
-
resolved.portal?.enabled === true && resolved.portal.console.enabled;
|
|
535
|
-
if (consoleOn && !raw.clientId) {
|
|
536
|
-
invalid(
|
|
537
|
-
"identity.clientId",
|
|
538
|
-
'identity.provider "oidc" needs "clientId" when the portal console is enabled.',
|
|
539
|
-
"It is the public client (PKCE, no secret) the portal signs in with.",
|
|
540
|
-
);
|
|
541
|
-
}
|
|
542
|
-
for (const field of ["native", "connections", "clients"] as const) {
|
|
543
|
-
if (raw[field] !== undefined)
|
|
544
|
-
invalid(
|
|
545
|
-
`identity.${field}`,
|
|
546
|
-
`identity.${field} is not available with provider "oidc": your own provider handles it.`,
|
|
547
|
-
);
|
|
548
|
-
}
|
|
549
|
-
if (raw.providerOptions?.mfa !== undefined) {
|
|
550
|
-
invalid(
|
|
551
|
-
"identity.providerOptions.mfa",
|
|
552
|
-
'identity.providerOptions.mfa is not available with provider "oidc".',
|
|
553
|
-
);
|
|
554
|
-
}
|
|
555
|
-
} else {
|
|
556
|
-
for (const field of ["issuer", "clientId"] as const) {
|
|
557
|
-
if (raw[field] !== undefined)
|
|
558
|
-
invalid(
|
|
559
|
-
`identity.${field}`,
|
|
560
|
-
`identity.${field} is only used with provider "oidc".`,
|
|
561
|
-
);
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
for (const [list, label] of [
|
|
566
|
-
[identity.connections, "connections"],
|
|
567
|
-
[identity.clients, "clients"],
|
|
568
|
-
] as const) {
|
|
569
|
-
const seen = new Set<string>();
|
|
570
|
-
for (const [index, item] of list.entries()) {
|
|
571
|
-
if (seen.has(item.id)) {
|
|
572
|
-
out.error({
|
|
573
|
-
code: "IDENTITY_DUPLICATE_ID",
|
|
574
|
-
path: `identity.${label}[${index}].id`,
|
|
575
|
-
message: `the id "${item.id}" is used more than once in identity.${label}.`,
|
|
576
|
-
});
|
|
577
|
-
}
|
|
578
|
-
seen.add(item.id);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
for (const [index, client] of identity.clients.entries()) {
|
|
583
|
-
if ((client.type === "web" || client.type === "m2m") && !client.secret) {
|
|
584
|
-
invalid(
|
|
585
|
-
`identity.clients[${index}].secret`,
|
|
586
|
-
`a ${client.type} client needs a secret.`,
|
|
587
|
-
'Set it to a secret reference, for example "secret:WORKER_SECRET".',
|
|
588
|
-
);
|
|
589
|
-
}
|
|
590
|
-
for (const [uriIndex, uri] of (client.redirectUris ?? []).entries()) {
|
|
591
|
-
try {
|
|
592
|
-
const url = new URL(uri);
|
|
593
|
-
if (url.protocol !== "https:" && !isLocalHost(url.hostname)) {
|
|
594
|
-
invalid(
|
|
595
|
-
`identity.clients[${index}].redirectUris[${uriIndex}]`,
|
|
596
|
-
"redirect URIs must use https, except for localhost.",
|
|
597
|
-
);
|
|
598
|
-
}
|
|
599
|
-
} catch {
|
|
600
|
-
invalid(
|
|
601
|
-
`identity.clients[${index}].redirectUris[${uriIndex}]`,
|
|
602
|
-
`"${uri}" is not a valid URL.`,
|
|
603
|
-
);
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// --- plans (2.6) ----------------------------------------------------------------------------------------------------
|
|
610
|
-
|
|
611
|
-
async function plansRules(
|
|
612
|
-
config: ConfigV2,
|
|
613
|
-
resolved: ResolvedConfigV2,
|
|
614
|
-
out: Collector,
|
|
615
|
-
) {
|
|
616
|
-
const plans = resolved.plans;
|
|
617
|
-
if (!plans?.enabled) return;
|
|
618
|
-
|
|
619
|
-
if (!(await isFile(plans.file))) {
|
|
620
|
-
out.error({
|
|
621
|
-
code: "PLANS_FILE_INVALID",
|
|
622
|
-
path: "plans.file",
|
|
623
|
-
message: `the plan catalog was not found: ${path.relative(resolved.configDir, plans.file)}.`,
|
|
624
|
-
hint: "Create it with `apitogo add plans`.",
|
|
625
|
-
});
|
|
626
|
-
} else {
|
|
627
|
-
try {
|
|
628
|
-
JSON.parse(await readFile(plans.file, "utf8"));
|
|
629
|
-
} catch (error) {
|
|
630
|
-
out.error({
|
|
631
|
-
code: "PLANS_FILE_INVALID",
|
|
632
|
-
path: "plans.file",
|
|
633
|
-
message: `the plan catalog is not valid JSON (${error instanceof Error ? error.message : String(error)}).`,
|
|
634
|
-
});
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
if (plans.billing.provider === "stripe") {
|
|
639
|
-
if (!config.plans?.billing?.apiKey) {
|
|
640
|
-
out.error({
|
|
641
|
-
code: "PLANS_BILLING_INCOMPLETE",
|
|
642
|
-
path: "plans.billing.apiKey",
|
|
643
|
-
message:
|
|
644
|
-
"plans.billing.apiKey is required when billing.provider is stripe.",
|
|
645
|
-
hint: 'Set it to a secret reference, for example "secret:STRIPE_API_KEY".',
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
|
-
const headless = !(resolved.portal?.enabled === true);
|
|
649
|
-
for (const field of ["successUrl", "cancelUrl"] as const) {
|
|
650
|
-
if (headless && !plans.billing[field]) {
|
|
651
|
-
out.error({
|
|
652
|
-
code: "PLANS_BILLING_INCOMPLETE",
|
|
653
|
-
path: `plans.billing.${field}`,
|
|
654
|
-
message: `plans.billing.${field} is required when there is no portal.`,
|
|
655
|
-
hint: "Checkout needs somewhere to send the customer back to.",
|
|
656
|
-
});
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// --- secrets (2.2) --------------------------------------------------------------------------------------------------
|
|
663
|
-
|
|
664
|
-
function secretRules(
|
|
665
|
-
config: ConfigV2,
|
|
666
|
-
context: CheckContext,
|
|
667
|
-
out: Collector,
|
|
668
|
-
skipped: string[],
|
|
669
|
-
) {
|
|
670
|
-
const refs = collectSecretRefs(config);
|
|
671
|
-
if (refs.length === 0) return;
|
|
672
|
-
if (!context.knownSecrets) {
|
|
673
|
-
skipped.push(
|
|
674
|
-
`secret references (${new Set(refs.map((r) => r.name)).size} found) were not checked: the project's secrets were not available (offline, not logged in, or this folder is not linked to a project)`,
|
|
675
|
-
);
|
|
676
|
-
return;
|
|
677
|
-
}
|
|
678
|
-
for (const ref of refs) {
|
|
679
|
-
if (!context.knownSecrets.has(ref.name)) {
|
|
680
|
-
out.error({
|
|
681
|
-
code: "SECRET_NOT_SET",
|
|
682
|
-
path: ref.path,
|
|
683
|
-
message: `secret ${ref.name} is not set.`,
|
|
684
|
-
hint: `Run: apitogo secrets set ${ref.name}`,
|
|
685
|
-
});
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
/** Runs every offline rule and returns the full report. */
|
|
691
|
-
export async function checkConfigV2(
|
|
692
|
-
config: ConfigV2,
|
|
693
|
-
resolved: ResolvedConfigV2,
|
|
694
|
-
context: CheckContext = {},
|
|
695
|
-
): Promise<CheckReport> {
|
|
696
|
-
const errors: ConfigIssue[] = [];
|
|
697
|
-
const warnings: ConfigIssue[] = [];
|
|
698
|
-
const skipped = [
|
|
699
|
-
"online rules were not run (provider capabilities, origin reachability, OIDC discovery, tier limits)",
|
|
700
|
-
];
|
|
701
|
-
const out: Collector = {
|
|
702
|
-
error: (issue) => errors.push(issue),
|
|
703
|
-
warn: (issue) => warnings.push(issue),
|
|
704
|
-
};
|
|
705
|
-
|
|
706
|
-
dependencyRules(config, resolved, out);
|
|
707
|
-
secretRules(config, context, out, skipped);
|
|
708
|
-
await portalRules(config, resolved, out);
|
|
709
|
-
await gatewayRules(resolved, out);
|
|
710
|
-
identityRules(config, resolved, out);
|
|
711
|
-
await plansRules(config, resolved, out);
|
|
712
|
-
|
|
713
|
-
return { errors, warnings, skipped };
|
|
714
|
-
}
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { glob } from "glob";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
import type { ConfigIssue } from "./errors.js";
|
|
6
|
+
import type { ResolvedConfigV2 } from "./resolve.js";
|
|
7
|
+
import type { ConfigV2 } from "./schema.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The rules of `apitogo check` that need no network (Reference, section 2). Online rules (provider capabilities,
|
|
11
|
+
* origin reachability, OIDC discovery, tier limits) are not here: they run against the platform in a later phase.
|
|
12
|
+
*
|
|
13
|
+
* Every problem carries a stable code, the config path and a hint. All problems are collected, never just the first.
|
|
14
|
+
* Services with `enabled: false` are ignored, as the Tenant Service Model says.
|
|
15
|
+
*/
|
|
16
|
+
export type CheckReport = {
|
|
17
|
+
errors: ConfigIssue[];
|
|
18
|
+
warnings: ConfigIssue[];
|
|
19
|
+
/** Rules that did not run, so a clean report is never mistaken for "everything was checked". */
|
|
20
|
+
skipped: string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type CheckContext = {
|
|
24
|
+
/** Names of the project's secrets, when they are known. Leave undefined when they cannot be listed. */
|
|
25
|
+
knownSecrets?: ReadonlySet<string>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type Collector = {
|
|
29
|
+
error: (issue: ConfigIssue) => void;
|
|
30
|
+
warn: (issue: ConfigIssue) => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const SPEC_MAX_BYTES = 5 * 1024 * 1024;
|
|
34
|
+
const SECRET_REFERENCE = /^secret:([A-Za-z_][A-Za-z0-9_]*)$/;
|
|
35
|
+
|
|
36
|
+
async function isFile(target: string): Promise<boolean> {
|
|
37
|
+
return (await stat(target).catch(() => undefined))?.isFile() === true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function isDirectory(target: string): Promise<boolean> {
|
|
41
|
+
return (await stat(target).catch(() => undefined))?.isDirectory() === true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Every `secret:NAME` reference in the config, with the path it was found at. */
|
|
45
|
+
export function collectSecretRefs(
|
|
46
|
+
config: unknown,
|
|
47
|
+
): { name: string; path: string }[] {
|
|
48
|
+
const found: { name: string; path: string }[] = [];
|
|
49
|
+
const walk = (value: unknown, at: string) => {
|
|
50
|
+
if (typeof value === "string") {
|
|
51
|
+
const match = SECRET_REFERENCE.exec(value);
|
|
52
|
+
if (match?.[1]) found.push({ name: match[1], path: at });
|
|
53
|
+
} else if (Array.isArray(value)) {
|
|
54
|
+
value.forEach((item, index) => walk(item, `${at}[${index}]`));
|
|
55
|
+
} else if (typeof value === "object" && value !== null) {
|
|
56
|
+
for (const [key, item] of Object.entries(value))
|
|
57
|
+
walk(item, at === "" ? key : `${at}.${key}`);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
walk(config, "");
|
|
61
|
+
return found;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- dependencies between services (2.1) ----------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
function dependencyRules(
|
|
67
|
+
config: ConfigV2,
|
|
68
|
+
resolved: ResolvedConfigV2,
|
|
69
|
+
out: Collector,
|
|
70
|
+
) {
|
|
71
|
+
const gatewayOn = resolved.gateway?.enabled === true;
|
|
72
|
+
const identityOn = resolved.identity?.enabled === true;
|
|
73
|
+
const oidcGateway = gatewayOn && resolved.gateway?.auth?.mode === "oidc";
|
|
74
|
+
const hasIdentitySource = identityOn || oidcGateway;
|
|
75
|
+
|
|
76
|
+
if (resolved.plans?.enabled) {
|
|
77
|
+
if (!gatewayOn) {
|
|
78
|
+
out.error({
|
|
79
|
+
code: "PLANS_GATEWAY_REQUIRED",
|
|
80
|
+
path: "plans",
|
|
81
|
+
message:
|
|
82
|
+
"plans needs the gateway service: entitlements and API keys are enforced there.",
|
|
83
|
+
hint: "Enable it with `apitogo add gateway`.",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (!hasIdentitySource) {
|
|
87
|
+
out.error({
|
|
88
|
+
code: "PLANS_IDENTITY_REQUIRED",
|
|
89
|
+
path: "plans",
|
|
90
|
+
message: "plans needs an identity source to know who subscribes.",
|
|
91
|
+
hint: "Enable identity (apitogo add identity) or set gateway.auth.mode to oidc.",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const requireAuth = config.portal?.requireAuth;
|
|
97
|
+
if (
|
|
98
|
+
resolved.portal?.enabled &&
|
|
99
|
+
requireAuth !== undefined &&
|
|
100
|
+
requireAuth !== false &&
|
|
101
|
+
!hasIdentitySource
|
|
102
|
+
) {
|
|
103
|
+
out.error({
|
|
104
|
+
code: "PORTAL_CAPABILITY_MISSING",
|
|
105
|
+
path: "portal.requireAuth",
|
|
106
|
+
message:
|
|
107
|
+
"portal.requireAuth needs an identity source. Enable identity (apitogo add identity) or set gateway.auth.mode to oidc.",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Only the dependency rules (2.1), for commands that edit the config before any file exists. */
|
|
113
|
+
export function checkDependencies(
|
|
114
|
+
config: ConfigV2,
|
|
115
|
+
resolved: ResolvedConfigV2,
|
|
116
|
+
): ConfigIssue[] {
|
|
117
|
+
const issues: ConfigIssue[] = [];
|
|
118
|
+
dependencyRules(config, resolved, {
|
|
119
|
+
error: (issue) => void issues.push(issue),
|
|
120
|
+
warn: () => undefined,
|
|
121
|
+
});
|
|
122
|
+
return issues;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- portal (2.3) ---------------------------------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
async function portalRules(
|
|
128
|
+
config: ConfigV2,
|
|
129
|
+
resolved: ResolvedConfigV2,
|
|
130
|
+
out: Collector,
|
|
131
|
+
) {
|
|
132
|
+
const portal = resolved.portal;
|
|
133
|
+
if (!portal?.enabled) return;
|
|
134
|
+
|
|
135
|
+
if (!portal.docs.enabled && !portal.console.enabled) {
|
|
136
|
+
out.error({
|
|
137
|
+
code: "PORTAL_NOTHING_ENABLED",
|
|
138
|
+
path: "portal",
|
|
139
|
+
message:
|
|
140
|
+
"portal has docs and console both disabled, so there is nothing to build.",
|
|
141
|
+
hint: "Enable portal.docs or portal.console, or switch the portal off with portal.enabled: false.",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (portal.docs.enabled) {
|
|
146
|
+
if (portal.docs.provider === "external") {
|
|
147
|
+
if (!config.portal?.docs?.url) {
|
|
148
|
+
out.error({
|
|
149
|
+
code: "PORTAL_DOCS_URL_INVALID",
|
|
150
|
+
path: "portal.docs.url",
|
|
151
|
+
message:
|
|
152
|
+
"portal.docs.url is required when docs.provider is external.",
|
|
153
|
+
hint: 'For example: "url": "https://docs.acme.com".',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (config.portal?.docs?.content !== undefined) {
|
|
157
|
+
out.error({
|
|
158
|
+
code: "PORTAL_CONFIG_INVALID",
|
|
159
|
+
path: "portal.docs.content",
|
|
160
|
+
message:
|
|
161
|
+
"portal.docs.content is not allowed when docs.provider is external: the docs live in the external tool.",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (config.portal?.search !== undefined) {
|
|
165
|
+
out.error({
|
|
166
|
+
code: "PORTAL_CONFIG_INVALID",
|
|
167
|
+
path: "portal.search",
|
|
168
|
+
message:
|
|
169
|
+
"portal.search is not allowed when docs.provider is external: the external tool does the searching.",
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (resolved.gateway?.enabled !== true) {
|
|
173
|
+
out.error({
|
|
174
|
+
code: "PORTAL_EXTERNAL_DOCS_NO_SPEC",
|
|
175
|
+
path: "portal.docs",
|
|
176
|
+
message:
|
|
177
|
+
"docs.provider external needs an OpenAPI source, but the gateway is not enabled.",
|
|
178
|
+
hint: "Enable the gateway with specs: its OpenAPI export is what the external docs tool reads.",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
if (config.portal?.docs?.url !== undefined) {
|
|
183
|
+
out.error({
|
|
184
|
+
code: "PORTAL_CONFIG_INVALID",
|
|
185
|
+
path: "portal.docs.url",
|
|
186
|
+
message:
|
|
187
|
+
'portal.docs.url is only used when docs.provider is "external".',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
await docsContentRule(config, portal.docs.content, resolved, out);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (
|
|
195
|
+
portal.console.enabled &&
|
|
196
|
+
!portal.docs.enabled &&
|
|
197
|
+
!resolved.identity?.enabled &&
|
|
198
|
+
!resolved.plans?.enabled
|
|
199
|
+
) {
|
|
200
|
+
out.warn({
|
|
201
|
+
code: "PORTAL_CONSOLE_EMPTY",
|
|
202
|
+
path: "portal.console",
|
|
203
|
+
message:
|
|
204
|
+
"the console is enabled but no service feeds it, so it has no sign-in, pricing, keys or usage pages.",
|
|
205
|
+
hint: "Enable identity or plans, or enable portal.docs.",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (await isFile(portal.theme)) {
|
|
210
|
+
try {
|
|
211
|
+
JSON.parse(await readFile(portal.theme, "utf8"));
|
|
212
|
+
} catch (error) {
|
|
213
|
+
out.error({
|
|
214
|
+
code: "PORTAL_THEME_INVALID",
|
|
215
|
+
path: "portal.theme",
|
|
216
|
+
message: `the theme file is not valid JSON (${error instanceof Error ? error.message : String(error)}).`,
|
|
217
|
+
hint: path.relative(resolved.configDir, portal.theme),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function docsContentRule(
|
|
224
|
+
config: ConfigV2,
|
|
225
|
+
patterns: readonly string[],
|
|
226
|
+
resolved: ResolvedConfigV2,
|
|
227
|
+
out: Collector,
|
|
228
|
+
) {
|
|
229
|
+
const include = patterns.filter((pattern) => !pattern.startsWith("!"));
|
|
230
|
+
const ignore = patterns
|
|
231
|
+
.filter((pattern) => pattern.startsWith("!"))
|
|
232
|
+
.map((pattern) => pattern.slice(1));
|
|
233
|
+
const matches =
|
|
234
|
+
include.length > 0
|
|
235
|
+
? await glob(include, { nodir: true, ignore, absolute: true })
|
|
236
|
+
: [];
|
|
237
|
+
if (matches.length > 0) return;
|
|
238
|
+
|
|
239
|
+
const shown = config.portal?.docs?.content ?? [
|
|
240
|
+
path.relative(resolved.configDir, patterns[0] ?? ""),
|
|
241
|
+
];
|
|
242
|
+
out.warn({
|
|
243
|
+
code: "PORTAL_DOCS_EMPTY",
|
|
244
|
+
path: "portal.docs",
|
|
245
|
+
message: `portal.docs matched 0 files (${shown.join(", ")}). The site will only contain the API reference.`,
|
|
246
|
+
hint: "Add .md or .mdx files, or point docs.content at your existing docs.",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// --- gateway (2.4) --------------------------------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
function isIpLiteral(hostname: string): boolean {
|
|
253
|
+
return /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.startsWith("[");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The credential the gateway sends to the origin: a header needs a name and a secret; mtls is not available yet. */
|
|
257
|
+
function originAuthRules(
|
|
258
|
+
origin: NonNullable<ResolvedConfigV2["gateway"]>["origin"],
|
|
259
|
+
out: Collector,
|
|
260
|
+
) {
|
|
261
|
+
const auth = origin.auth;
|
|
262
|
+
if (!auth) return;
|
|
263
|
+
const base = "gateway.origin.auth";
|
|
264
|
+
if (auth.type === "header") {
|
|
265
|
+
if (!auth.name)
|
|
266
|
+
out.error({
|
|
267
|
+
code: "GATEWAY_ORIGIN_INCOMPLETE",
|
|
268
|
+
path: `${base}.name`,
|
|
269
|
+
message: "origin.auth.type is header, so origin.auth.name is required.",
|
|
270
|
+
hint: 'For example: "X-Origin-Key".',
|
|
271
|
+
});
|
|
272
|
+
if (!auth.value)
|
|
273
|
+
out.error({
|
|
274
|
+
code: "GATEWAY_ORIGIN_INCOMPLETE",
|
|
275
|
+
path: `${base}.value`,
|
|
276
|
+
message:
|
|
277
|
+
"origin.auth.type is header, so origin.auth.value is required.",
|
|
278
|
+
hint: 'A secret reference, for example "secret:ORIGIN_KEY". Store it with `apitogo secrets set ORIGIN_KEY`.',
|
|
279
|
+
});
|
|
280
|
+
} else if (auth.type === "none" && (auth.name || auth.value)) {
|
|
281
|
+
out.error({
|
|
282
|
+
code: "GATEWAY_ORIGIN_INVALID",
|
|
283
|
+
path: base,
|
|
284
|
+
message: "origin.auth.type is none, but a name or value is set.",
|
|
285
|
+
hint: 'Remove them, or set the type to "header".',
|
|
286
|
+
});
|
|
287
|
+
} else if (auth.type === "mtls") {
|
|
288
|
+
out.error({
|
|
289
|
+
code: "GATEWAY_CAPABILITY_UNSUPPORTED",
|
|
290
|
+
path: `${base}.type`,
|
|
291
|
+
message:
|
|
292
|
+
"Client certificates (mtls) for the origin are not available yet.",
|
|
293
|
+
hint: 'Use type "header" with a secret.',
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function gatewayRules(resolved: ResolvedConfigV2, out: Collector) {
|
|
299
|
+
const gateway = resolved.gateway;
|
|
300
|
+
if (!gateway?.enabled) return;
|
|
301
|
+
const origin = gateway.origin;
|
|
302
|
+
|
|
303
|
+
(gateway.policies.cors ?? []).forEach((site, index) => {
|
|
304
|
+
const { pathname, search, hash } = new URL(site);
|
|
305
|
+
if (pathname === "/" && !search && !hash && !site.endsWith("/")) return;
|
|
306
|
+
out.error({
|
|
307
|
+
code: "GATEWAY_CORS_ORIGIN_INVALID",
|
|
308
|
+
path: `gateway.policies.cors[${index}]`,
|
|
309
|
+
message: `"${site}" is not a browser origin: it has a path, a query or a trailing slash.`,
|
|
310
|
+
hint: `Use only scheme and host, for example "${new URL(site).origin}".`,
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
if (origin.mode === "managed") {
|
|
315
|
+
for (const field of ["source", "runtime", "runtimeVersion"] as const) {
|
|
316
|
+
if (!origin[field]) {
|
|
317
|
+
out.error({
|
|
318
|
+
code: "GATEWAY_ORIGIN_INCOMPLETE",
|
|
319
|
+
path: `gateway.origin.${field}`,
|
|
320
|
+
message: `gateway.origin.${field} is required when origin.mode is managed.`,
|
|
321
|
+
hint:
|
|
322
|
+
field === "runtimeVersion"
|
|
323
|
+
? 'For example: "NODE_22_LTS". The platform publishes the allowed values.'
|
|
324
|
+
: undefined,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (origin.source) {
|
|
329
|
+
const inside = path.relative(resolved.configDir, origin.source);
|
|
330
|
+
if (inside.startsWith("..") || path.isAbsolute(inside)) {
|
|
331
|
+
out.error({
|
|
332
|
+
code: "GATEWAY_ORIGIN_INVALID",
|
|
333
|
+
path: "gateway.origin.source",
|
|
334
|
+
message:
|
|
335
|
+
"gateway.origin.source must stay inside the project directory.",
|
|
336
|
+
});
|
|
337
|
+
} else if (!(await isDirectory(origin.source))) {
|
|
338
|
+
out.error({
|
|
339
|
+
code: "GATEWAY_ORIGIN_INVALID",
|
|
340
|
+
path: "gateway.origin.source",
|
|
341
|
+
message: `gateway.origin.source does not exist: ${inside === "" ? "." : inside}.`,
|
|
342
|
+
hint: "Point it at the folder that holds your backend code.",
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (origin.url !== undefined) {
|
|
347
|
+
out.error({
|
|
348
|
+
code: "GATEWAY_ORIGIN_INVALID",
|
|
349
|
+
path: "gateway.origin.url",
|
|
350
|
+
message: "gateway.origin.url is only used when origin.mode is proxy.",
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
if (!origin.url) {
|
|
355
|
+
out.error({
|
|
356
|
+
code: "GATEWAY_ORIGIN_INVALID_URL",
|
|
357
|
+
path: "gateway.origin.url",
|
|
358
|
+
message: "gateway.origin.url is required when origin.mode is proxy.",
|
|
359
|
+
hint: 'For example: "https://api.acme.com".',
|
|
360
|
+
});
|
|
361
|
+
} else {
|
|
362
|
+
const url = new URL(origin.url);
|
|
363
|
+
if (url.username !== "" || url.password !== "") {
|
|
364
|
+
out.error({
|
|
365
|
+
code: "GATEWAY_ORIGIN_INVALID_URL",
|
|
366
|
+
path: "gateway.origin.url",
|
|
367
|
+
message: "gateway.origin.url must not contain credentials.",
|
|
368
|
+
hint: "Use an origin secret header instead.",
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
if (isIpLiteral(url.hostname)) {
|
|
372
|
+
out.error({
|
|
373
|
+
code: "GATEWAY_ORIGIN_INVALID_URL",
|
|
374
|
+
path: "gateway.origin.url",
|
|
375
|
+
message:
|
|
376
|
+
"gateway.origin.url must use a host name, not an IP address.",
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
for (const field of [
|
|
381
|
+
"source",
|
|
382
|
+
"runtime",
|
|
383
|
+
"runtimeVersion",
|
|
384
|
+
"exclude",
|
|
385
|
+
] as const) {
|
|
386
|
+
if (origin[field] !== undefined) {
|
|
387
|
+
out.error({
|
|
388
|
+
code: "GATEWAY_ORIGIN_INVALID",
|
|
389
|
+
path: `gateway.origin.${field}`,
|
|
390
|
+
message: `gateway.origin.${field} is only used when origin.mode is managed.`,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
originAuthRules(origin, out);
|
|
397
|
+
if (gateway.specs.length > 1) {
|
|
398
|
+
out.warn({
|
|
399
|
+
code: "GATEWAY_SPEC_INVALID",
|
|
400
|
+
path: "gateway.specs",
|
|
401
|
+
message:
|
|
402
|
+
"gateway.specs lists several files, but the platform takes one OpenAPI file per gateway, so deploy will refuse it.",
|
|
403
|
+
hint: "Merge them into one file.",
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
for (const [index, spec] of gateway.specs.entries())
|
|
408
|
+
await specRule(spec, index, resolved, out);
|
|
409
|
+
|
|
410
|
+
authRule(resolved, out);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function specRule(
|
|
414
|
+
spec: string,
|
|
415
|
+
index: number,
|
|
416
|
+
resolved: ResolvedConfigV2,
|
|
417
|
+
out: Collector,
|
|
418
|
+
) {
|
|
419
|
+
const at = `gateway.specs[${index}]`;
|
|
420
|
+
const shown = path.relative(resolved.configDir, spec);
|
|
421
|
+
const info = await stat(spec).catch(() => undefined);
|
|
422
|
+
if (!info?.isFile()) {
|
|
423
|
+
out.error({
|
|
424
|
+
code: "GATEWAY_SPEC_INVALID",
|
|
425
|
+
path: at,
|
|
426
|
+
message: `OpenAPI file not found: ${shown}.`,
|
|
427
|
+
});
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (info.size > SPEC_MAX_BYTES) {
|
|
431
|
+
out.error({
|
|
432
|
+
code: "GATEWAY_SPEC_INVALID",
|
|
433
|
+
path: at,
|
|
434
|
+
message: `${shown} is ${(info.size / 1024 / 1024).toFixed(1)} MB; the limit is 5 MB.`,
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
let document: unknown;
|
|
439
|
+
try {
|
|
440
|
+
const text = await readFile(spec, "utf8");
|
|
441
|
+
document = spec.toLowerCase().endsWith(".json")
|
|
442
|
+
? JSON.parse(text)
|
|
443
|
+
: parseYaml(text);
|
|
444
|
+
} catch (error) {
|
|
445
|
+
out.error({
|
|
446
|
+
code: "GATEWAY_SPEC_INVALID",
|
|
447
|
+
path: at,
|
|
448
|
+
message: `${shown} cannot be parsed (${error instanceof Error ? error.message.split("\n")[0] : String(error)}).`,
|
|
449
|
+
});
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const version = (document as { openapi?: unknown } | null)?.openapi;
|
|
453
|
+
if (typeof version !== "string" || !/^3\.[01]\./.test(version)) {
|
|
454
|
+
out.error({
|
|
455
|
+
code: "GATEWAY_SPEC_INVALID",
|
|
456
|
+
path: at,
|
|
457
|
+
message: `${shown} is not an OpenAPI 3.0 or 3.1 document.`,
|
|
458
|
+
hint: 'It needs a top-level "openapi": "3.0.x" or "3.1.x".',
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function authRule(resolved: ResolvedConfigV2, out: Collector) {
|
|
464
|
+
const auth = resolved.gateway?.auth;
|
|
465
|
+
if (!auth) return;
|
|
466
|
+
|
|
467
|
+
if (auth.mode === "identity") {
|
|
468
|
+
if (!resolved.identity?.enabled) {
|
|
469
|
+
out.error({
|
|
470
|
+
code: "GATEWAY_AUTH_INCOMPLETE",
|
|
471
|
+
path: "gateway.auth.mode",
|
|
472
|
+
message:
|
|
473
|
+
"gateway.auth.mode is identity but the identity service is not enabled.",
|
|
474
|
+
hint: "Enable it with `apitogo add identity`, or use gateway.auth.mode oidc with your own provider.",
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const parts = {
|
|
481
|
+
metadataUrl: auth.metadataUrl ? 1 : 0,
|
|
482
|
+
issuers: auth.issuers?.length ?? 0,
|
|
483
|
+
audiences: auth.audiences?.length ?? 0,
|
|
484
|
+
};
|
|
485
|
+
const present = Object.entries(parts)
|
|
486
|
+
.filter(([, count]) => count > 0)
|
|
487
|
+
.map(([name]) => name);
|
|
488
|
+
const missing = Object.entries(parts)
|
|
489
|
+
.filter(([, count]) => count === 0)
|
|
490
|
+
.map(([name]) => name);
|
|
491
|
+
if (missing.length === 0) return;
|
|
492
|
+
|
|
493
|
+
const blocked = "Token validation would be skipped, so deploy is blocked.";
|
|
494
|
+
if (present.length === 0) {
|
|
495
|
+
out.error({
|
|
496
|
+
code: "GATEWAY_AUTH_INCOMPLETE",
|
|
497
|
+
path: "gateway.auth",
|
|
498
|
+
message: `gateway.auth.mode is oidc but metadataUrl, issuers and audiences are all empty. ${blocked}`,
|
|
499
|
+
});
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
for (const name of missing) {
|
|
503
|
+
out.error({
|
|
504
|
+
code: "GATEWAY_AUTH_INCOMPLETE",
|
|
505
|
+
path: "gateway.auth",
|
|
506
|
+
message: `gateway.auth: ${present[0]} is set but ${name} is empty. ${blocked}`,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// --- identity (2.5) -------------------------------------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
function isLocalHost(hostname: string): boolean {
|
|
514
|
+
return (
|
|
515
|
+
hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function identityRules(
|
|
520
|
+
config: ConfigV2,
|
|
521
|
+
resolved: ResolvedConfigV2,
|
|
522
|
+
out: Collector,
|
|
523
|
+
) {
|
|
524
|
+
const identity = resolved.identity;
|
|
525
|
+
const raw = config.identity;
|
|
526
|
+
if (!identity?.enabled || !raw) return;
|
|
527
|
+
const invalid = (at: string, message: string, hint?: string) =>
|
|
528
|
+
out.error({ code: "IDENTITY_CONFIG_INVALID", path: at, message, hint });
|
|
529
|
+
|
|
530
|
+
if (identity.provider === "oidc") {
|
|
531
|
+
if (!raw.issuer)
|
|
532
|
+
invalid("identity.issuer", 'identity.provider "oidc" requires "issuer".');
|
|
533
|
+
const consoleOn =
|
|
534
|
+
resolved.portal?.enabled === true && resolved.portal.console.enabled;
|
|
535
|
+
if (consoleOn && !raw.clientId) {
|
|
536
|
+
invalid(
|
|
537
|
+
"identity.clientId",
|
|
538
|
+
'identity.provider "oidc" needs "clientId" when the portal console is enabled.',
|
|
539
|
+
"It is the public client (PKCE, no secret) the portal signs in with.",
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
for (const field of ["native", "connections", "clients"] as const) {
|
|
543
|
+
if (raw[field] !== undefined)
|
|
544
|
+
invalid(
|
|
545
|
+
`identity.${field}`,
|
|
546
|
+
`identity.${field} is not available with provider "oidc": your own provider handles it.`,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
if (raw.providerOptions?.mfa !== undefined) {
|
|
550
|
+
invalid(
|
|
551
|
+
"identity.providerOptions.mfa",
|
|
552
|
+
'identity.providerOptions.mfa is not available with provider "oidc".',
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
} else {
|
|
556
|
+
for (const field of ["issuer", "clientId"] as const) {
|
|
557
|
+
if (raw[field] !== undefined)
|
|
558
|
+
invalid(
|
|
559
|
+
`identity.${field}`,
|
|
560
|
+
`identity.${field} is only used with provider "oidc".`,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
for (const [list, label] of [
|
|
566
|
+
[identity.connections, "connections"],
|
|
567
|
+
[identity.clients, "clients"],
|
|
568
|
+
] as const) {
|
|
569
|
+
const seen = new Set<string>();
|
|
570
|
+
for (const [index, item] of list.entries()) {
|
|
571
|
+
if (seen.has(item.id)) {
|
|
572
|
+
out.error({
|
|
573
|
+
code: "IDENTITY_DUPLICATE_ID",
|
|
574
|
+
path: `identity.${label}[${index}].id`,
|
|
575
|
+
message: `the id "${item.id}" is used more than once in identity.${label}.`,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
seen.add(item.id);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
for (const [index, client] of identity.clients.entries()) {
|
|
583
|
+
if ((client.type === "web" || client.type === "m2m") && !client.secret) {
|
|
584
|
+
invalid(
|
|
585
|
+
`identity.clients[${index}].secret`,
|
|
586
|
+
`a ${client.type} client needs a secret.`,
|
|
587
|
+
'Set it to a secret reference, for example "secret:WORKER_SECRET".',
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
for (const [uriIndex, uri] of (client.redirectUris ?? []).entries()) {
|
|
591
|
+
try {
|
|
592
|
+
const url = new URL(uri);
|
|
593
|
+
if (url.protocol !== "https:" && !isLocalHost(url.hostname)) {
|
|
594
|
+
invalid(
|
|
595
|
+
`identity.clients[${index}].redirectUris[${uriIndex}]`,
|
|
596
|
+
"redirect URIs must use https, except for localhost.",
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
} catch {
|
|
600
|
+
invalid(
|
|
601
|
+
`identity.clients[${index}].redirectUris[${uriIndex}]`,
|
|
602
|
+
`"${uri}" is not a valid URL.`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// --- plans (2.6) ----------------------------------------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
async function plansRules(
|
|
612
|
+
config: ConfigV2,
|
|
613
|
+
resolved: ResolvedConfigV2,
|
|
614
|
+
out: Collector,
|
|
615
|
+
) {
|
|
616
|
+
const plans = resolved.plans;
|
|
617
|
+
if (!plans?.enabled) return;
|
|
618
|
+
|
|
619
|
+
if (!(await isFile(plans.file))) {
|
|
620
|
+
out.error({
|
|
621
|
+
code: "PLANS_FILE_INVALID",
|
|
622
|
+
path: "plans.file",
|
|
623
|
+
message: `the plan catalog was not found: ${path.relative(resolved.configDir, plans.file)}.`,
|
|
624
|
+
hint: "Create it with `apitogo add plans`.",
|
|
625
|
+
});
|
|
626
|
+
} else {
|
|
627
|
+
try {
|
|
628
|
+
JSON.parse(await readFile(plans.file, "utf8"));
|
|
629
|
+
} catch (error) {
|
|
630
|
+
out.error({
|
|
631
|
+
code: "PLANS_FILE_INVALID",
|
|
632
|
+
path: "plans.file",
|
|
633
|
+
message: `the plan catalog is not valid JSON (${error instanceof Error ? error.message : String(error)}).`,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (plans.billing.provider === "stripe") {
|
|
639
|
+
if (!config.plans?.billing?.apiKey) {
|
|
640
|
+
out.error({
|
|
641
|
+
code: "PLANS_BILLING_INCOMPLETE",
|
|
642
|
+
path: "plans.billing.apiKey",
|
|
643
|
+
message:
|
|
644
|
+
"plans.billing.apiKey is required when billing.provider is stripe.",
|
|
645
|
+
hint: 'Set it to a secret reference, for example "secret:STRIPE_API_KEY".',
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
const headless = !(resolved.portal?.enabled === true);
|
|
649
|
+
for (const field of ["successUrl", "cancelUrl"] as const) {
|
|
650
|
+
if (headless && !plans.billing[field]) {
|
|
651
|
+
out.error({
|
|
652
|
+
code: "PLANS_BILLING_INCOMPLETE",
|
|
653
|
+
path: `plans.billing.${field}`,
|
|
654
|
+
message: `plans.billing.${field} is required when there is no portal.`,
|
|
655
|
+
hint: "Checkout needs somewhere to send the customer back to.",
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// --- secrets (2.2) --------------------------------------------------------------------------------------------------
|
|
663
|
+
|
|
664
|
+
function secretRules(
|
|
665
|
+
config: ConfigV2,
|
|
666
|
+
context: CheckContext,
|
|
667
|
+
out: Collector,
|
|
668
|
+
skipped: string[],
|
|
669
|
+
) {
|
|
670
|
+
const refs = collectSecretRefs(config);
|
|
671
|
+
if (refs.length === 0) return;
|
|
672
|
+
if (!context.knownSecrets) {
|
|
673
|
+
skipped.push(
|
|
674
|
+
`secret references (${new Set(refs.map((r) => r.name)).size} found) were not checked: the project's secrets were not available (offline, not logged in, or this folder is not linked to a project)`,
|
|
675
|
+
);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
for (const ref of refs) {
|
|
679
|
+
if (!context.knownSecrets.has(ref.name)) {
|
|
680
|
+
out.error({
|
|
681
|
+
code: "SECRET_NOT_SET",
|
|
682
|
+
path: ref.path,
|
|
683
|
+
message: `secret ${ref.name} is not set.`,
|
|
684
|
+
hint: `Run: apitogo secrets set ${ref.name}`,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Runs every offline rule and returns the full report. */
|
|
691
|
+
export async function checkConfigV2(
|
|
692
|
+
config: ConfigV2,
|
|
693
|
+
resolved: ResolvedConfigV2,
|
|
694
|
+
context: CheckContext = {},
|
|
695
|
+
): Promise<CheckReport> {
|
|
696
|
+
const errors: ConfigIssue[] = [];
|
|
697
|
+
const warnings: ConfigIssue[] = [];
|
|
698
|
+
const skipped = [
|
|
699
|
+
"online rules were not run (provider capabilities, origin reachability, OIDC discovery, tier limits)",
|
|
700
|
+
];
|
|
701
|
+
const out: Collector = {
|
|
702
|
+
error: (issue) => errors.push(issue),
|
|
703
|
+
warn: (issue) => warnings.push(issue),
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
dependencyRules(config, resolved, out);
|
|
707
|
+
secretRules(config, context, out, skipped);
|
|
708
|
+
await portalRules(config, resolved, out);
|
|
709
|
+
await gatewayRules(resolved, out);
|
|
710
|
+
identityRules(config, resolved, out);
|
|
711
|
+
await plansRules(config, resolved, out);
|
|
712
|
+
|
|
713
|
+
return { errors, warnings, skipped };
|
|
714
|
+
}
|