diff --git a/distribution/all-in-one/docker-compose.yaml b/distribution/all-in-one/docker-compose.yaml
index df6724706c..06a26b8e95 100644
--- a/distribution/all-in-one/docker-compose.yaml
+++ b/distribution/all-in-one/docker-compose.yaml
@@ -51,9 +51,18 @@ services:
# platform-api signs its tokens with RS256; the devportal verifies them
# against jwt_public.pem from the shared keypair volume mounted below
# (auth.local.jwt_public_key in devportal-config.toml). There is no shared
- # HMAC secret. tls_skip_verify covers platform-api's self-signed cert.
+ # HMAC secret.
- APIP_AP_AUTH_LOCAL_PLATFORM_API_URL=https://platform-api:9243
- - APIP_AP_AUTH_LOCAL_TLS_SKIP_VERIFY=true
+ # Trust platform-api's self-signed cert at Node's trust-store level instead of
+ # skipping verification per client. NODE_EXTRA_CA_CERTS *appends* to Node's
+ # built-in CAs (public CAs keep working) and applies process-wide, so it covers
+ # BOTH the auth login client (authController's https.Agent passes no custom
+ # `ca`, so it honours this) AND the webhook delivery worker
+ # (src/services/webhooks/deliveryWorker.js), which POSTs events such as
+ # apikey.generated over Node's default TLS and has no tls_skip_verify hatch of
+ # its own. That is why APIP_AP_AUTH_LOCAL_TLS_SKIP_VERIFY is no longer set here:
+ # verification stays ON for every platform-api call, self-signed cert and all.
+ - NODE_EXTRA_CA_CERTS=/etc/api-portal/certs/cert.pem
# Required — devportal fails closed at startup if either doesn't resolve
# to a 64-char hex string. Set both before `docker compose up`, e.g.:
# export APIP_AP_SECURITY_ENCRYPTION_KEY=$(openssl rand -hex 32)
@@ -70,11 +79,24 @@ services:
# Same RS256 keypair volume platform-api signs with — the devportal reads
# only jwt_public.pem (0644) from it to verify those tokens.
- platform-api-jwt-keys:/etc/api-portal/keys:ro
+ # platform-api's self-signed TLS cert, trusted via NODE_EXTRA_CA_CERTS above.
+ # Subpath mount exposes only the public cert.pem — never the private key.pem
+ # that also lives in this volume (same pattern as the e2e compose).
+ - type: volume
+ source: platform-api-certs
+ target: /etc/api-portal/certs/cert.pem
+ read_only: true
+ volume:
+ subpath: cert.pem
depends_on:
postgres:
condition: service_healthy
platform-api:
condition: service_healthy
+ # cert.pem must exist in the certs volume before Node reads NODE_EXTRA_CA_CERTS
+ # at startup (a missing file is silently ignored with a warning).
+ platform-api-certgen:
+ condition: service_completed_successfully
platform-api-jwtkeygen:
condition: service_completed_successfully
@@ -85,11 +107,20 @@ services:
entrypoint: ["/bin/sh", "-c"]
command:
- |
- [ -f /certs/cert.pem ] && [ -f /certs/key.pem ] && exit 0
- openssl req -x509 -newkey rsa:2048 -sha256 -days 365 -nodes \
- -keyout /certs/key.pem -out /certs/cert.pem \
- -subj "/O=WSO2 API Platform/CN=platform-api" \
- -addext "subjectAltName=DNS:localhost,DNS:platform-api,IP:127.0.0.1"
+ set -e
+ if [ ! -f /certs/cert.pem ] || [ ! -f /certs/key.pem ]; then
+ openssl req -x509 -newkey rsa:2048 -sha256 -days 365 -nodes \
+ -keyout /certs/key.pem -out /certs/cert.pem \
+ -subj "/O=WSO2 API Platform/CN=platform-api" \
+ -addext "subjectAltName=DNS:localhost,DNS:platform-api,IP:127.0.0.1"
+ fi
+ # platform-api runs as uid 10001 and reads both files. Keep the private key
+ # owned by that uid and non-world-readable; keep the public cert world-readable
+ # so api-portal can trust it via NODE_EXTRA_CA_CERTS. Applied every run (like
+ # platform-api-jwtkeygen) so a cached certs volume gets the same permissions.
+ chown 10001 /certs/key.pem
+ chmod 0600 /certs/key.pem
+ chmod 0644 /certs/cert.pem
volumes:
- platform-api-certs:/certs
diff --git a/portals/api-portal/src/controllers/apiContentController.js b/portals/api-portal/src/controllers/apiContentController.js
index 1e9eb08c38..bd1bc82c3b 100644
--- a/portals/api-portal/src/controllers/apiContentController.js
+++ b/portals/api-portal/src/controllers/apiContentController.js
@@ -587,6 +587,12 @@ function requestOrigin(req) {
return `${protocol}://${req.get('host')}`;
}
+const TRYOUT_CAPABLE_TYPES = new Set([
+ constants.API_TYPE.WS,
+ constants.API_TYPE.WEBSUB,
+ constants.API_TYPE.GRAPHQL,
+]);
+
const loadDocument = async (req, res, next) => {
const { orgName, apiHandle, viewName, docType, docName } = req.params;
@@ -599,11 +605,14 @@ const loadDocument = async (req, res, next) => {
isAPIDefinition: false,
isWebSocketTryout: false,
isGraphQLTryout: false,
+ isTryout: false,
};
templateContent.apiType = definitionResponse.apiType;
+ templateContent.supportsTryout = TRYOUT_CAPABLE_TYPES.has(definitionResponse.apiType);
if (isSpecPage && definitionResponse.swagger) {
const specType = definitionResponse.apiType;
- const tryoutEnabled = !!req.query.tryout;
+ const tryoutEnabled = req.query.tryout === true || req.query.tryout === 'true';
+ templateContent.isTryout = templateContent.supportsTryout && tryoutEnabled;
if (specType === constants.API_TYPE.WS || specType === constants.API_TYPE.WEBSUB) {
templateContent.asyncapi = JSON.stringify(parseApiDefinitionContent(definitionResponse.swagger));
templateContent.isWebSocketTryout = tryoutEnabled;
@@ -653,12 +662,20 @@ const loadDocument = async (req, res, next) => {
let templateContent = {
"isAPIDefinition": false,
"isWebSocketTryout": false,
- "isGraphQLTryout": false
+ "isGraphQLTryout": false,
+ "isTryout": false
};
const definitionResponse = await getAPIDefinition(orgName, viewName, apiHandle);
templateContent.apiType = definitionResponse.apiType;
-
- const tryoutEnabled = req.query.tryout ? true : false;
+
+ // Only an explicit tryout=true opts in, and only on the specification page —
+ // the tryout payload (asyncapi/GraphQL introspection) is loaded only there, so a
+ // non-spec document must never render the tryout console. Any other query value
+ // (e.g. tryout=false) is a truthy string and must not enable it.
+ const isSpecPage = req.originalUrl.includes(constants.FILE_NAME.API_SPECIFICATION_PATH);
+ const tryoutEnabled = isSpecPage && (req.query.tryout === true || req.query.tryout === 'true');
+ templateContent.supportsTryout = TRYOUT_CAPABLE_TYPES.has(definitionResponse.apiType);
+ templateContent.isTryout = templateContent.supportsTryout && tryoutEnabled;
if (definitionResponse.apiType === constants.API_TYPE.WS || definitionResponse.apiType === constants.API_TYPE.WEBSUB) {
templateContent.isWebSocketTryout = tryoutEnabled;
} else if (definitionResponse.apiType === constants.API_TYPE.GRAPHQL) {
@@ -667,7 +684,7 @@ const loadDocument = async (req, res, next) => {
let apiMetadata = definitionResponse.metaData;
//load API definition
- if (req.originalUrl.includes(constants.FILE_NAME.API_SPECIFICATION_PATH)) {
+ if (isSpecPage) {
if (definitionResponse.apiType === constants.API_TYPE.MCP) {
// The playground reads its server URL from servers[0].url. A
diff --git a/portals/api-portal/src/controllers/apiKeysPageController.js b/portals/api-portal/src/controllers/apiKeysPageController.js
index 6d623620ec..4a3671285e 100644
--- a/portals/api-portal/src/controllers/apiKeysPageController.js
+++ b/portals/api-portal/src/controllers/apiKeysPageController.js
@@ -51,7 +51,7 @@ const loadAPIApiKeys = async (req, res, next) => {
const images = metaData.apiImageMetadata;
if (images) {
for (const key in images) {
- images[key] = `${constants.API_PORTAL_API.orgPath(orgId)}${constants.ROUTE.API_FILE_PATH}${apiId}${constants.API_TEMPLATE_FILE_NAME}${images[key]}`;
+ images[key] = `${constants.API_PORTAL_API.orgPath(orgId)}${constants.ROUTE.API_FILE_PATH}${metaData.id}${constants.API_TEMPLATE_FILE_NAME}${images[key]}`;
}
}
} else {
diff --git a/portals/api-portal/src/controllers/apiWorkflowsController.js b/portals/api-portal/src/controllers/apiWorkflowsController.js
index 6c2d4eb8c0..798841529f 100644
--- a/portals/api-portal/src/controllers/apiWorkflowsController.js
+++ b/portals/api-portal/src/controllers/apiWorkflowsController.js
@@ -23,7 +23,7 @@ const apiWorkflowService = require('../services/apiWorkflowService');
const { config } = require('../config/configLoader');
const logger = require('../config/logger');
const util = require('../utils/util');
-const { loadLayoutFromAPI, renderGivenTemplate, renderTemplateFromAPI, rewriteViewStyles, isAiDisabledForPortal } = require('../utils/util');
+const { loadLayoutFromAPI, renderGivenTemplate, renderTemplateFromAPI, rewriteViewStyles, isAiDisabledForPortal, resolveAiEnabled } = require('../utils/util');
const constants = require('../utils/constants');
const fs = require('fs');
const path = require('path');
@@ -114,6 +114,11 @@ const loadAPIWorkflows = async (req, res, next) => {
viewName,
baseUrl: `/${orgName}/views/${viewName}`,
profile,
+ // Gate the AI Ready chips on the real AI toggle, not the artifact type.
+ // Set explicitly so the custom-view renderGivenTemplate path (which hardcodes
+ // aiEnabled: true) also respects it, matching renderTemplateFromAPI. Uses the
+ // fail-open helper so a transient config-read error doesn't reject the page.
+ aiEnabled: await resolveAiEnabled(orgId, viewName),
};
const dbLayout = await loadLayoutFromAPI(orgId, viewName);
@@ -194,6 +199,11 @@ const loadAPIWorkflowDetail = async (req, res, next) => {
viewName,
baseUrl: `/${orgName}/views/${viewName}`,
profile,
+ // Gate the "Try with AI" button/modal on the real AI toggle. Set explicitly so
+ // the custom-view renderGivenTemplate path (which hardcodes aiEnabled: true) also
+ // respects it, matching renderTemplateFromAPI. Uses the fail-open helper so a
+ // transient config-read error doesn't reject the page.
+ aiEnabled: await resolveAiEnabled(orgId, viewName),
};
const dbLayout = await loadLayoutFromAPI(orgId, viewName);
diff --git a/portals/api-portal/src/controllers/viewConfigureController.js b/portals/api-portal/src/controllers/viewConfigureController.js
index d4c97a1562..593f70fc98 100644
--- a/portals/api-portal/src/controllers/viewConfigureController.js
+++ b/portals/api-portal/src/controllers/viewConfigureController.js
@@ -142,6 +142,12 @@ const loadSettingsPage = async (req, res) => {
}
templateContent.orgLabels = orgLabels;
+ const labelNameByHandle = new Map(orgLabels.map(l => [l.id, l.displayName]));
+ templateContent.views = views.map(view => ({
+ ...view,
+ labelNames: (view.labels || []).map(handle => labelNameByHandle.get(handle) || handle),
+ }));
+
let orgPlans = [];
try {
const plansRaw = await subscriptionPlanDao.list(orgId);
diff --git a/portals/api-portal/src/defaultContent/images/graphql-icon.svg b/portals/api-portal/src/defaultContent/images/graphql-icon.svg
new file mode 100644
index 0000000000..ebf6590ba5
--- /dev/null
+++ b/portals/api-portal/src/defaultContent/images/graphql-icon.svg
@@ -0,0 +1 @@
+
diff --git a/portals/api-portal/src/defaultContent/images/websocket-icon.svg b/portals/api-portal/src/defaultContent/images/websocket-icon.svg
new file mode 100644
index 0000000000..faae2d35b4
--- /dev/null
+++ b/portals/api-portal/src/defaultContent/images/websocket-icon.svg
@@ -0,0 +1 @@
+
diff --git a/portals/api-portal/src/defaultContent/pages/api-landing/page.hbs b/portals/api-portal/src/defaultContent/pages/api-landing/page.hbs
index cab115e649..f75d27ea23 100644
--- a/portals/api-portal/src/defaultContent/pages/api-landing/page.hbs
+++ b/portals/api-portal/src/defaultContent/pages/api-landing/page.hbs
@@ -58,7 +58,7 @@
{{> alert }}
{{!-- ── AGENT PROMPT MODAL ── --}}
-{{#if showApiWorkflowsNav}}
+{{#if aiEnabled}}
{{#unless (eq apiMetadata.agentVisibility "HIDDEN")}}
{{> api-agent-prompt-modal}}
{{/unless}}
diff --git a/portals/api-portal/src/defaultContent/pages/api-landing/partials/api-detail-banner.hbs b/portals/api-portal/src/defaultContent/pages/api-landing/partials/api-detail-banner.hbs
index 763b70da8a..f314c72732 100644
--- a/portals/api-portal/src/defaultContent/pages/api-landing/partials/api-detail-banner.hbs
+++ b/portals/api-portal/src/defaultContent/pages/api-landing/partials/api-detail-banner.hbs
@@ -18,14 +18,16 @@
{{#if (eq apiMetadata.type "GRAPHQL")}}dp-badge--graphql
{{else if (eq apiMetadata.type "WS")}}dp-badge--ws
{{else if (eq apiMetadata.type "SOAP")}}dp-badge--soap
+ {{else if (eq apiMetadata.type "WebSubApi")}}dp-badge--ws
{{else}}dp-badge--rest{{/if}}">
- {{#if (eq apiMetadata.type "GRAPHQL")}} GraphQL
- {{else if (eq apiMetadata.type "WS")}} WebSocket
+ {{#if (eq apiMetadata.type "GRAPHQL")}} GraphQL
+ {{else if (eq apiMetadata.type "WS")}}
WebSocket
+ {{else if (eq apiMetadata.type "WebSubApi")}} WebSub
{{else if (eq apiMetadata.type "SOAP")}} SOAP
- {{else}} {{apiMetadata.type}}{{/if}}
+ {{else}} REST{{/if}}
{{/if}}
- {{#if showApiWorkflowsNav}}
+ {{#if aiEnabled}}
{{#unless (eq apiMetadata.agentVisibility "HIDDEN")}}
AI Ready
@@ -68,7 +70,7 @@
{{/if}}
{{/in}}
- {{#if showApiWorkflowsNav}}
+ {{#if aiEnabled}}
{{#unless (eq apiMetadata.agentVisibility "HIDDEN")}}
Create a workflow to encode a multi-step API sequence your AI agents can follow. Published workflows will appear here for every consumer.
- {{#if profile.isAdmin}} - - Create workflow - - {{/if}} +There are no published workflows available right now. Once they're published, they'll appear here for your AI agents to follow.
{{/if}} diff --git a/portals/api-portal/src/defaultContent/pages/api-workflows/partials/workflow-card.hbs b/portals/api-portal/src/defaultContent/pages/api-workflows/partials/workflow-card.hbs index 52522c94c7..6c712e6d60 100644 --- a/portals/api-portal/src/defaultContent/pages/api-workflows/partials/workflow-card.hbs +++ b/portals/api-portal/src/defaultContent/pages/api-workflows/partials/workflow-card.hbs @@ -17,10 +17,12 @@ {{/if}} diff --git a/portals/api-portal/src/defaultContent/pages/apis/partials/api-listing.hbs b/portals/api-portal/src/defaultContent/pages/apis/partials/api-listing.hbs index 4f021dfedc..01150283bc 100644 --- a/portals/api-portal/src/defaultContent/pages/apis/partials/api-listing.hbs +++ b/portals/api-portal/src/defaultContent/pages/apis/partials/api-listing.hbs @@ -101,13 +101,13 @@ {{else if (eq type "SOAP")}}dp-badge--soap {{else if (eq type "WebSubApi")}}dp-badge--ws {{else}}dp-badge--rest{{/if}}"> - {{#if (eq type "GRAPHQL")}} GraphQL - {{else if (eq type "WS")}} WebSocket + {{#if (eq type "GRAPHQL")}}
@@ -27,7 +27,7 @@
Browse our MCP servers
{{/if}}
- {{#if showApiWorkflowsNav}}
+ {{#if aiEnabled}}