From 2044a3237f1e957f8c32e14298a5565fdc41b5fd Mon Sep 17 00:00:00 2001 From: Lasantha Samarakoon Date: Wed, 15 Jul 2026 18:55:32 +0530 Subject: [PATCH 1/8] Replace hardcoded devportal session secret with configured value --- portals/developer-portal/configs/config-template.toml | 1 + portals/developer-portal/src/app.js | 3 +-- portals/developer-portal/src/config/configDefaults.js | 1 + portals/developer-portal/src/config/configLoader.js | 9 +++++++++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/portals/developer-portal/configs/config-template.toml b/portals/developer-portal/configs/config-template.toml index 8ade2c485..58d2ba23f 100644 --- a/portals/developer-portal/configs/config-template.toml +++ b/portals/developer-portal/configs/config-template.toml @@ -66,6 +66,7 @@ ca_file = "./resources/security/ca.pem" [security] encryption_key = "" # 64-char hex — AES-256-GCM key for encrypting secrets at rest +session_secret = "" # 64-char hex — express-session signing secret role_validation = false # Enforce per-operation role validation # Static shared-secret header for calling the devportal's own REST API. diff --git a/portals/developer-portal/src/app.js b/portals/developer-portal/src/app.js index fc1e08983..6e2e40b55 100644 --- a/portals/developer-portal/src/app.js +++ b/portals/developer-portal/src/app.js @@ -45,8 +45,7 @@ const { configurePassport } = require('./middlewares/passportConfig'); const app = express(); // Do not advertise Express in response headers. app.disable('x-powered-by'); -// const secret = crypto.randomBytes(64).toString('hex'); -const sessionSecret = 'my-secret'; +const sessionSecret = config.security.sessionSecret; const SERVER_ID = uuidv4(); diff --git a/portals/developer-portal/src/config/configDefaults.js b/portals/developer-portal/src/config/configDefaults.js index dde7373d4..2cf64d998 100644 --- a/portals/developer-portal/src/config/configDefaults.js +++ b/portals/developer-portal/src/config/configDefaults.js @@ -56,6 +56,7 @@ const DEFAULTS = { }, security: { encryptionKey: '', + sessionSecret: '', roleValidation: false, // was: advanced.disabledRoleValidation, inverted serviceApiKey: { enabled: true, diff --git a/portals/developer-portal/src/config/configLoader.js b/portals/developer-portal/src/config/configLoader.js index ca22c117a..bd971a325 100644 --- a/portals/developer-portal/src/config/configLoader.js +++ b/portals/developer-portal/src/config/configLoader.js @@ -172,4 +172,13 @@ if (!config.security.encryptionKey || !/^[0-9a-fA-F]{64}$/.test(config.security. ); } +if (!config.security.sessionSecret || !/^[0-9a-fA-F]{64}$/.test(config.security.sessionSecret)) { + config.security.sessionSecret = crypto.randomBytes(32).toString('hex'); + process.stderr.write( + '[WARN] security.sessionSecret is not set — generated an ephemeral key. ' + + 'Existing sessions will be invalidated on restart. ' + + 'Set APIP_DP_SECURITY_SESSIONSECRET in your .env file to persist it.\n' + ); +} + module.exports = { config }; From 23adc6eb218d8eb2759fd47422195e5a27de56d1 Mon Sep 17 00:00:00 2001 From: Lasantha Samarakoon Date: Wed, 15 Jul 2026 19:22:03 +0530 Subject: [PATCH 2/8] Replace automatic APIP_DP_* env override with explicit {{ env }}/{{ file }} config interpolation --- .../configs/config-template.toml | 34 +- portals/developer-portal/configs/config.toml | 42 ++- .../it/docker-compose.test.postgres.yaml | 4 + .../it/docker-compose.test.yaml | 4 + portals/developer-portal/it/test-config.toml | 5 + .../src/config/configDefaults.js | 5 +- .../src/config/configLoader.js | 317 +++++++++++++----- 7 files changed, 299 insertions(+), 112 deletions(-) diff --git a/portals/developer-portal/configs/config-template.toml b/portals/developer-portal/configs/config-template.toml index 58d2ba23f..8a2b5d9cb 100644 --- a/portals/developer-portal/configs/config-template.toml +++ b/portals/developer-portal/configs/config-template.toml @@ -2,18 +2,34 @@ # DEVELOPER PORTAL CONFIGURATION TEMPLATE # ============================================================================= # Documentation reference only — enumerates every supported config.toml key -# with its default value. To actually run the portal, copy config.toml.example -# instead (same keys, with setup-oriented comments and copy/paste guidance). +# with its default value, as plain literals. See configs/config.toml for the +# concrete file this project actually ships, which wires some of these same +# keys up to environment variables using the syntax below. # # Precedence (lowest to highest): -# 1. Built-in defaults (src/config/configLoader.js) -# 2. configs/config.toml -# 3. APIP_DP_* environment variables +# 1. Built-in defaults (src/config/configDefaults.js) +# 2. configs/config.toml, with any {{ env }} / {{ file }} references resolved # -# Every key below can be overridden via APIP_DP___... (one -# underscore-separated token per config level, case-insensitive, "__" for a -# literal underscore in a key name). Example: security.service_api_key.value -# -> APIP_DP_SECURITY_SERVICEAPIKEY_VALUE. +# There is no automatic environment-variable override. A key's value comes +# from an env var or a file ONLY if you explicitly say so, using Handlebars- +# style template syntax as the value. Use single-quoted TOML literal strings +# (as below) so the inner double quotes need no backslash-escaping: +# +# base_url = '{{ env "APIP_DP_SERVER_BASEURL" }}' +# encryption_key = '{{ file "/secrets/devportal/encryption-key" }}' +# +# {{ env "NAME" }} -> NAME's value. FAILS CLOSED (aborts startup) +# if NAME is unset or empty — it does not +# fall through to the built-in default shown +# below. +# {{ env "NAME" "default" }} -> NAME's value if set, else the literal +# "default". +# {{ file "/path" }} -> contents of /path, trimmed. Always +# required — a missing/unreadable/ +# disallowed file is a hard startup error. +# +# Partial substitution works too: 'foo-{{ env "X" }}' resolves to "foo-bar" +# if X=bar. # ============================================================================= # SERVER CONFIGURATION diff --git a/portals/developer-portal/configs/config.toml b/portals/developer-portal/configs/config.toml index 5e420388a..2c640a818 100644 --- a/portals/developer-portal/configs/config.toml +++ b/portals/developer-portal/configs/config.toml @@ -5,25 +5,31 @@ # their default values. # # Precedence (lowest to highest): -# 1. Built-in defaults -# 2. This file (configs/config.toml) -# 3. APIP_DP_* environment variables +# 1. Built-in defaults (src/config/configDefaults.js) +# 2. This file, with any {{ env }} / {{ file }} references resolved # -# Every setting can also be overridden via an APIP_DP_* environment variable: +# There is no automatic APIP_DP_* environment-variable override — an env var (or +# a mounted file) only takes effect where a key below explicitly references it, +# using Handlebars-style template syntax as the value. Use single-quoted TOML +# literal strings (as below) so the inner double quotes need no escaping: # -# Convention: APIP_DP___... -# - Each underscore-separated token maps to one config level (case-insensitive) -# - Double underscores (__) represent a literal underscore within a key name -# - Keys in this file are snake_case; the matching env token is the camelCase -# struct key collapsed to a single uppercase word (e.g. base_url -> BASEURL) +# base_url = '{{ env "APIP_DP_SERVER_BASEURL" }}' +# encryption_key = '{{ file "/secrets/devportal/encryption-key" }}' # -# Examples: -# APIP_DP_DATABASE_HOST=myhost -> database.host -# APIP_DP_DATABASE_PASSWORD=pass -> database.password -# APIP_DP_IDP_CLIENTID=abc -> idp.clientId -# APIP_DP_SECURITY_SERVICEAPIKEY_VALUE=secret -> security.serviceApiKey.value -# APIP_DP_WEBHOOKS_DELIVERY_SIGNATURETOLERANCESEC -> webhooks.delivery.signatureToleranceSec +# {{ env "NAME" }} -> NAME's value. FAILS CLOSED (aborts startup) if +# NAME is unset or empty — it does not fall +# through to the built-in default shown above. +# {{ env "NAME" "default" }} -> NAME's value if set, else the literal +# "default". +# {{ file "/path" }} -> contents of /path, trimmed. Always required — +# a missing/unreadable/disallowed file is a +# hard startup error. # -# You can place env var overrides in a .env file at the project root. -# -# Nothing overridden yet — every setting is at its built-in default. +# Partial substitution works too: 'foo-{{ env "X" }}' resolves to "foo-bar" if +# X=bar. See src/config/configLoader.js for the full implementation. + +[security] +# Required — devportal fails closed at startup if either doesn't resolve to a +# 64-char hex string. Generate with: openssl rand -hex 32 +encryption_key = '{{ env "APIP_DP_SECURITY_ENCRYPTIONKEY" }}' +session_secret = '{{ env "APIP_DP_SECURITY_SESSIONSECRET" }}' diff --git a/portals/developer-portal/it/docker-compose.test.postgres.yaml b/portals/developer-portal/it/docker-compose.test.postgres.yaml index 669f608d9..f4bc27bfb 100644 --- a/portals/developer-portal/it/docker-compose.test.postgres.yaml +++ b/portals/developer-portal/it/docker-compose.test.postgres.yaml @@ -98,6 +98,10 @@ services: APIP_DP_DATABASE_PASSWORD: devportal APIP_DP_DATABASE_NAME: devportal APIP_DP_SERVER_BASEURL: "http://devportal:3000" + # devportal fails closed at startup without these — fixed test-only values, + # not meant to be reused outside this CI fixture. + APIP_DP_SECURITY_ENCRYPTIONKEY: "7f40672c96fd437dc33550755e218d586014232ceb5b01c9405aab575bcb1f4a" + APIP_DP_SECURITY_SESSIONSECRET: "527948cfb2478dbd4dfcd9d466df65d8c70511236fe63dba0da0f5385676f593" healthcheck: test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""] interval: 10s diff --git a/portals/developer-portal/it/docker-compose.test.yaml b/portals/developer-portal/it/docker-compose.test.yaml index 685f92b26..207606b73 100644 --- a/portals/developer-portal/it/docker-compose.test.yaml +++ b/portals/developer-portal/it/docker-compose.test.yaml @@ -75,6 +75,10 @@ services: APIP_DP_DATABASE_TYPE: sqlite APIP_DP_DATABASE_FILE: /tmp/devportal-it.db APIP_DP_SERVER_BASEURL: "http://devportal:3000" + # devportal fails closed at startup without these — fixed test-only values, + # not meant to be reused outside this CI fixture. + APIP_DP_SECURITY_ENCRYPTIONKEY: "7f40672c96fd437dc33550755e218d586014232ceb5b01c9405aab575bcb1f4a" + APIP_DP_SECURITY_SESSIONSECRET: "527948cfb2478dbd4dfcd9d466df65d8c70511236fe63dba0da0f5385676f593" healthcheck: test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""] interval: 10s diff --git a/portals/developer-portal/it/test-config.toml b/portals/developer-portal/it/test-config.toml index ff203c3db..24fbf7496 100644 --- a/portals/developer-portal/it/test-config.toml +++ b/portals/developer-portal/it/test-config.toml @@ -12,6 +12,11 @@ enabled = false [logging] console_only = true +[security] +# Required — devportal fails closed at startup without these. +encryption_key = '{{ env "APIP_DP_SECURITY_ENCRYPTIONKEY" }}' +session_secret = '{{ env "APIP_DP_SECURITY_SESSIONSECRET" }}' + [security.service_api_key] enabled = true value = "devportal-it-test-key" diff --git a/portals/developer-portal/src/config/configDefaults.js b/portals/developer-portal/src/config/configDefaults.js index 2cf64d998..0ad1fd350 100644 --- a/portals/developer-portal/src/config/configDefaults.js +++ b/portals/developer-portal/src/config/configDefaults.js @@ -24,7 +24,10 @@ * camelCase — configs/config.toml uses snake_case and is converted to camelCase * on load (see configLoader.js) before being merged over this struct. * - * Effective config precedence: DEFAULTS → configs/config.toml → APIP_DP_* env vars. + * Effective config precedence: DEFAULTS → configs/config.toml (with any + * {{ env }} / {{ file }} references resolved — see configLoader.js). There is + * no separate, automatic APIP_DP_* env-var override layer; an env var only + * takes effect where config.toml explicitly references it. */ const DEFAULTS = { server: { diff --git a/portals/developer-portal/src/config/configLoader.js b/portals/developer-portal/src/config/configLoader.js index bd971a325..07ec7c769 100644 --- a/portals/developer-portal/src/config/configLoader.js +++ b/portals/developer-portal/src/config/configLoader.js @@ -20,13 +20,13 @@ const path = require('path'); const fs = require('fs'); -const crypto = require('crypto'); const toml = require('smol-toml'); +const Handlebars = require('handlebars'); const { DEFAULTS } = require('./configDefaults'); -// Load .env file if present (silently ignored if absent) +// Load api-platform.env if present (silently ignored if absent) try { - require('dotenv').config({ path: path.join(process.cwd(), '.env') }); + require('dotenv').config({ path: path.join(process.cwd(), 'api-platform.env') }); } catch (_) {} /** @@ -54,8 +54,8 @@ function snakeToCamelDeep(value) { /** * Load configs/config.toml (snake_case), converted to camelCase. - * Returns an empty object if the file does not exist, so DEFAULTS + env vars - * alone can drive the app. + * Returns an empty object if the file does not exist, so DEFAULTS alone can + * drive the app. */ function loadTomlConfig() { const tomlPath = path.join(process.cwd(), 'configs', 'config.toml'); @@ -67,57 +67,155 @@ function loadTomlConfig() { return {}; } -/** - * Deep-merge src into dst (src wins on conflicts) — used to layer config.toml - * over a clone of DEFAULTS. - */ -function mergeOver(dst, src) { - for (const [k, v] of Object.entries(src)) { - if (v !== null && typeof v === 'object' && !Array.isArray(v) && - dst[k] !== null && typeof dst[k] === 'object' && !Array.isArray(dst[k])) { - mergeOver(dst[k], v); - } else { - dst[k] = v; - } +// --------------------------------------------------------------------------- +// Config value interpolation: {{ env "NAME" ["fallback"] }} / {{ file "/path" }} +// --------------------------------------------------------------------------- +// +// This is the JS counterpart to common/configinterpolate — the Go package +// platform-api uses for the same purpose (see platform-api/config/config.go) — +// and follows the exact same contract so the two config files read the same +// way to an operator: +// +// {{ env "NAME" }} -> value of NAME; FAILS CLOSED (aborts startup) +// if NAME is unset or empty. A set-but-empty +// variable counts as unset (bash ${NAME:?} +// semantics). +// {{ env "NAME" "fallback" }} -> value of NAME if set and non-empty, else the +// literal "fallback". +// {{ file "/path" }} -> the trimmed contents of /path. Always +// required — missing, unreadable, oversize, or +// disallowed is a hard startup error. +// +// There is no automatic APIP_DP_* prefix mapping anymore (removed the same way +// platform-api removed its koanf env-prefix mapping) — a variable only takes +// effect where config.toml explicitly references it via {{ env "..." }}. +// +// A dedicated Handlebars instance (Handlebars.create()) is used rather than the +// shared singleton src/helpers/handlebarsHelpers.js registers page-rendering +// helpers on, so config interpolation and page templates never share helpers. + +const hb = Handlebars.create(); + +// Directories a {{ file "..." }} path may read from by default. Overridable via +// the APIP_CONFIG_FILE_SOURCE_ALLOWLIST env var — shared across every +// api-platform component (see common/configinterpolate.EnvFileSourceAllowlist), +// read directly rather than through {{ env }} since it gates interpolation +// itself and so can't be one of its own references. +const DEFAULT_FILE_ALLOWLIST = ['/etc/devportal', '/secrets/devportal']; +const FILE_ALLOWLIST_ENV_VAR = 'APIP_CONFIG_FILE_SOURCE_ALLOWLIST'; + +// Secret files (tokens, keys, passwords) are far smaller than this; the cap +// guards against accidentally reading a huge file into memory. +const MAX_FILE_BYTES = 1 << 20; // 1 MiB + +function getFileAllowlist() { + const raw = process.env[FILE_ALLOWLIST_ENV_VAR]; + if (!raw || !raw.trim()) return DEFAULT_FILE_ALLOWLIST; + const dirs = raw.split(',').map(d => d.trim()).filter(Boolean); + return dirs.length ? dirs : DEFAULT_FILE_ALLOWLIST; +} + +function isAllowed(candidatePath, allowlist) { + return allowlist.some(dir => candidatePath.startsWith(path.normalize(dir) + path.sep)); +} + +// Resolves an allowlist root's symlinks so it can be compared against a +// symlink-resolved candidate file path. A root that doesn't exist falls back to +// its cleaned form — harmless, since no readable file could live under it. +function resolveAllowlistRoot(dir) { + const cleaned = path.resolve(dir); + try { + return fs.realpathSync(cleaned); + } catch (_) { + return cleaned; } - return dst; +} + +function isAllowedResolved(resolvedPath, allowlist) { + return allowlist.some(dir => resolvedPath.startsWith(resolveAllowlistRoot(dir) + path.sep)); } /** - * Deep-set a value in an object given a path expressed as an array of lowercase key tokens. - * At each level, keys are matched case-insensitively (so "dbsecret" matches "dbSecret"). - * If no matching key exists, the token itself is used as the new key name. + * Reads an allowlisted file for {{ file "..." }}, enforcing this project's + * file-access rules: null-byte/traversal rejection, allowlist containment on the + * input path, symlink resolution and a second containment check against the + * resolved path (prevents a TOCTOU swap between the check and the read), and a + * size cap. Error messages name the operator-supplied path only — never the + * file contents or the allowlist. */ -const BLOCKED_KEYS = new Set(['__proto__', 'prototype', 'constructor']); +function readAllowedFile(inputPath) { + if (inputPath.includes('\0')) { + throw new Error(`file "${inputPath}" is not in an allowed source directory`); + } + const cleaned = path.normalize(inputPath); + if (cleaned.includes('..')) { + throw new Error(`file "${inputPath}" is not in an allowed source directory`); + } -function deepSet(obj, tokens, value) { - if (!tokens.length || typeof obj !== 'object' || obj === null) return; + const allowlist = getFileAllowlist(); + if (!allowlist.length) { + throw new Error('file interpolation not permitted: no allowlist configured'); + } + if (!isAllowed(cleaned, allowlist)) { + throw new Error(`file "${inputPath}" is not in an allowed source directory`); + } - const token = tokens[0]; - const rest = tokens.slice(1); + let resolved; + try { + resolved = fs.realpathSync(cleaned); + } catch (_) { + throw new Error(`required file "${inputPath}" is not found`); + } + if (!isAllowedResolved(resolved, allowlist)) { + throw new Error(`file "${inputPath}" is not in an allowed source directory`); + } - if (BLOCKED_KEYS.has(token)) return; + let stat; + try { + stat = fs.statSync(resolved); + } catch (_) { + throw new Error(`required file "${inputPath}" is not found`); + } + if (stat.size > MAX_FILE_BYTES) { + throw new Error(`file "${inputPath}" exceeds the maximum allowed size`); + } - // Find an existing own-property key whose lowercase form matches the token - const existingKey = Object.keys(obj).find(k => - Object.prototype.hasOwnProperty.call(obj, k) && k.toLowerCase() === token - ); - const key = existingKey !== undefined ? existingKey : token; + let data; + try { + data = fs.readFileSync(resolved, 'utf8'); + } catch (_) { + throw new Error(`required file "${inputPath}" is not found`); + } + return data.replace(/[ \t\r\n]+$/, ''); +} - if (BLOCKED_KEYS.has(key)) return; +hb.registerHelper('env', function envHelper(...args) { + args.pop(); // discard the Handlebars options object, always the last argument + const [name, fallback] = args; + if (typeof name !== 'string' || !name) { + throw new Error('{{ env }} requires a variable name, e.g. {{ env "APIP_DP_X" }}'); + } + envRefCount += 1; + const value = process.env[name]; + if (value !== undefined && value !== '') return value; + if (typeof fallback === 'string') return fallback; + throw new Error(`required env var "${name}" is not found`); +}); - if (rest.length === 0) { - obj[key] = coerceValue(value); - } else { - if (typeof obj[key] !== 'object' || obj[key] === null) { - obj[key] = {}; - } - deepSet(obj[key], rest, value); +hb.registerHelper('file', function fileHelper(...args) { + args.pop(); + const [filePath] = args; + if (typeof filePath !== 'string' || !filePath) { + throw new Error('{{ file }} requires a path, e.g. {{ file "/secrets/x" }}'); } -} + fileRefCount += 1; + return readAllowedFile(filePath); +}); /** - * Coerce a string env var value to the most appropriate JS type. + * Coerce a string value (post-interpolation) to the most appropriate JS type. + * Only ever applied to leaves that were actually templated — a plain TOML + * literal keeps its native TOML type and is never passed through this. */ function coerceValue(value) { if (value === 'true') return true; @@ -126,59 +224,110 @@ function coerceValue(value) { return value; } +let envRefCount = 0; +let fileRefCount = 0; +let fieldCount = 0; + /** - * Apply APIP_DP_* environment variable overrides onto the config object. - * - * Convention: - * - Prefix: APIP_DP_ - * - _ separates nesting levels (one token per config object level) - * - __ represents a literal underscore within a key name - * - Tokens are matched case-insensitively against existing config keys - * - * Examples: - * APIP_DP_DATABASE_HOST → config.database.host - * APIP_DP_IDP_CLIENTID → config.idp.clientId - * APIP_DP_DATABASE_PASSWORD → config.database.password - * APIP_DP_SECURITY_SERVICEAPIKEY_VALUE → config.security.serviceApiKey.value - * APIP_DP_WEBHOOKS_DELIVERY_SIGNATURETOLERANCESEC → config.webhooks.delivery.signatureToleranceSec + * Interpolate a single TOML leaf. fieldPath is a dotted/bracketed path (e.g. + * "security.encryptionKey" or "idp.scopes[0]") used only to point at which + * field failed if interpolation throws. + */ +function interpolateLeaf(value, fieldPath) { + if (typeof value !== 'string' || !value.includes('{{')) { + // No template syntax at all — a plain literal, passed through with its + // native TOML type/value, never coerced. + return value; + } + fieldCount += 1; + try { + const compiled = hb.compile(value, { noEscape: true, strict: true }); + return coerceValue(compiled({})); + } catch (err) { + throw new Error(`config interpolation failed at "${fieldPath}": ${err.message}`); + } +} + +/** + * Recursively interpolate every string leaf of a parsed config.toml object + * (including array elements), before it's merged over DEFAULTS. + */ +function interpolateTree(value, fieldPath) { + if (Array.isArray(value)) { + return value.map((item, i) => interpolateTree(item, `${fieldPath}[${i}]`)); + } + if (value !== null && typeof value === 'object') { + const out = {}; + for (const [k, v] of Object.entries(value)) { + out[k] = interpolateTree(v, fieldPath ? `${fieldPath}.${k}` : k); + } + return out; + } + return interpolateLeaf(value, fieldPath); +} + +/** + * Prototype-pollution guard, applied to the DEFAULTS<-config.toml merge below. + */ +const BLOCKED_KEYS = new Set(['__proto__', 'prototype', 'constructor']); + +/** + * Deep-merge src into dst (src wins on conflicts) — used to layer the + * interpolated config.toml tree over a clone of DEFAULTS. */ -const ENV_PREFIX = 'APIP_DP_'; - -function applyEnvOverrides(config) { - const PLACEHOLDER = '\x00'; - for (const [key, value] of Object.entries(process.env)) { - if (!key.startsWith(ENV_PREFIX)) continue; - const withoutPrefix = key.slice(ENV_PREFIX.length); - // Escape __ → placeholder, split on _, restore placeholder → _ - const tokens = withoutPrefix - .replace(/__/g, PLACEHOLDER) - .split('_') - .map(t => t.replace(new RegExp(PLACEHOLDER, 'g'), '_').toLowerCase()); - deepSet(config, tokens, value); +function mergeOver(dst, src) { + for (const [k, v] of Object.entries(src)) { + if (BLOCKED_KEYS.has(k)) continue; + if (v !== null && typeof v === 'object' && !Array.isArray(v) && + dst[k] !== null && typeof dst[k] === 'object' && !Array.isArray(dst[k])) { + mergeOver(dst[k], v); + } else { + dst[k] = v; + } } + return dst; } -// Precedence: DEFAULTS (source of truth) → configs/config.toml → APIP_DP_* env vars. -const config = mergeOver(JSON.parse(JSON.stringify(DEFAULTS)), loadTomlConfig()); -applyEnvOverrides(config); +const rawTomlConfig = loadTomlConfig(); -if (!config.security.encryptionKey || !/^[0-9a-fA-F]{64}$/.test(config.security.encryptionKey)) { - config.security.encryptionKey = crypto.randomBytes(32).toString('hex'); +let interpolatedTomlConfig; +try { + interpolatedTomlConfig = interpolateTree(rawTomlConfig, ''); +} catch (err) { // Use process.stderr directly — logger is not yet initialised at this point - process.stderr.write( - '[WARN] security.encryptionKey is not set — generated an ephemeral key. ' + - 'Encrypted data (subscription tokens, webhook secrets) will be unreadable after restart. ' + - 'Set APIP_DP_SECURITY_ENCRYPTIONKEY in your .env file to persist it.\n' - ); + process.stderr.write(`[FATAL] ${err.message}\n`); + process.exit(1); } -if (!config.security.sessionSecret || !/^[0-9a-fA-F]{64}$/.test(config.security.sessionSecret)) { - config.security.sessionSecret = crypto.randomBytes(32).toString('hex'); +// Precedence: DEFAULTS (source of truth) → configs/config.toml, with {{ env }}/ +// {{ file }} references resolved before the merge. +const config = mergeOver(JSON.parse(JSON.stringify(DEFAULTS)), interpolatedTomlConfig); + +if (fieldCount > 0) { process.stderr.write( - '[WARN] security.sessionSecret is not set — generated an ephemeral key. ' + - 'Existing sessions will be invalidated on restart. ' + - 'Set APIP_DP_SECURITY_SESSIONSECRET in your .env file to persist it.\n' + `[INFO] Config: resolved ${envRefCount} env reference(s), ${fileRefCount} file reference(s) across ${fieldCount} field(s).\n` ); } +/** + * Fail-closed startup check: required security secrets must be present and + * valid before the application is allowed to start. There is no ephemeral/ + * generated fallback — a missing or malformed secret aborts the process + * immediately rather than starting with a weaker, silently-regenerated one. + */ +function requireHexSecret(value, fieldName) { + if (!value || !/^[0-9a-fA-F]{64}$/.test(value)) { + process.stderr.write( + `[FATAL] security.${fieldName} did not resolve to a 64-character hex string. ` + + 'Refusing to start with a missing or malformed secret. ' + + 'Generate one with: openssl rand -hex 32 — then reference it from configs/config.toml, ' + + `e.g. ${fieldName === 'encryptionKey' ? 'encryption_key' : 'session_secret'} = '{{ env "APIP_DP_SECURITY_${fieldName === 'encryptionKey' ? 'ENCRYPTIONKEY' : 'SESSIONSECRET'}" }}'.\n` + ); + process.exit(1); + } +} + +requireHexSecret(config.security.encryptionKey, 'encryptionKey'); +requireHexSecret(config.security.sessionSecret, 'sessionSecret'); + module.exports = { config }; From cebb3ca689436ff2615db4df3b9d86c7ef0e2b51 Mon Sep 17 00:00:00 2001 From: Lasantha Samarakoon Date: Thu, 16 Jul 2026 08:45:14 +0530 Subject: [PATCH 3/8] Restructure devportal distribution zip layout: db-scripts and certificates under resources/, add setup.sh script dir --- portals/developer-portal/.dockerignore | 7 + portals/developer-portal/.gitignore | 4 + portals/developer-portal/Makefile | 9 +- portals/developer-portal/README.md | 2 +- ...mple => config-platform-api-template.toml} | 0 portals/developer-portal/configs/config.toml | 29 +++ .../distribution/docker-compose.yaml | 126 ++++------ .../docker-compose.platform-api.yaml | 27 +- portals/developer-portal/docker-compose.yaml | 83 +++--- portals/developer-portal/docker-entrypoint.sh | 20 +- .../docs/administer/manage-organizations.md | 6 +- .../docs/introduction/quick-start.md | 2 +- .../it/configs/config-platform-api-it.toml | 2 +- .../it/docker-compose.test.yaml | 2 +- portals/developer-portal/setup.sh | 237 ++++++++++++++++++ 15 files changed, 409 insertions(+), 147 deletions(-) rename portals/developer-portal/configs/{config-platform-api.toml.example => config-platform-api-template.toml} (100%) create mode 100755 portals/developer-portal/setup.sh diff --git a/portals/developer-portal/.dockerignore b/portals/developer-portal/.dockerignore index 7759613d5..54c40df1d 100644 --- a/portals/developer-portal/.dockerignore +++ b/portals/developer-portal/.dockerignore @@ -8,6 +8,13 @@ configs/ sample.config.toml .env .env.* +api-platform.env +api-platform.env.* + +# ── setup.sh output: bind-mounted TLS cert ──────────────────────── +# Must never be baked into the image — a stale copy here can silently +# resurface a removed/rotated secret on `docker compose up` without `--build`. +resources/certificates/ # ── Docker / distribution files ─────────────────────────────────── docker-compose.yml diff --git a/portals/developer-portal/.gitignore b/portals/developer-portal/.gitignore index 316d3328e..ed729ddad 100644 --- a/portals/developer-portal/.gitignore +++ b/portals/developer-portal/.gitignore @@ -98,7 +98,11 @@ resources/default-layout resources/mock .env +api-platform.env configs/config-platform-api.toml +# ./setup.sh output: bind-mounted TLS cert +resources/certificates/ + # Test reports it/reports/* diff --git a/portals/developer-portal/Makefile b/portals/developer-portal/Makefile index 89469da5d..f7683de59 100644 --- a/portals/developer-portal/Makefile +++ b/portals/developer-portal/Makefile @@ -140,11 +140,16 @@ DIST_ZIP := target/$(DIST_NAME).zip dist: clean-dist ## Build standalone developer portal distribution zip @echo "Building distribution $(DIST_NAME)..." @mkdir -p $(DIST_DIR)/configs - @cp -R database $(DIST_DIR) + @mkdir -p $(DIST_DIR)/resources/developer-portal/db-scripts + @cp -R database/* $(DIST_DIR)/resources/developer-portal/db-scripts @cp configs/config.toml $(DIST_DIR)/configs/config.toml @cp configs/config-template.toml $(DIST_DIR)/configs/config-template.toml - @cp configs/config-platform-api.toml.example $(DIST_DIR)/configs/config-platform-api.toml + @cp configs/config-platform-api-template.toml $(DIST_DIR)/configs/config-platform-api.toml + @cp configs/config-platform-api-template.toml $(DIST_DIR)/configs/config-platform-api-template.toml @cp distribution/docker-compose.yaml $(DIST_DIR)/docker-compose.yaml + @mkdir -p $(DIST_DIR)/scripts + @cp setup.sh $(DIST_DIR)/scripts/setup.sh + @chmod +x $(DIST_DIR)/scripts/setup.sh @sed -i.bak \ -e 's|image: .*/developer-portal:[^[:space:]]*|image: $(DEVPORTAL_IMAGE):$(DIST_VERSION)|g' \ $(DIST_DIR)/docker-compose.yaml diff --git a/portals/developer-portal/README.md b/portals/developer-portal/README.md index e9765a34e..db0b69ee8 100644 --- a/portals/developer-portal/README.md +++ b/portals/developer-portal/README.md @@ -260,7 +260,7 @@ The full annotated list of settings is in [`configs/config.toml.example`](config ### Local auth -For quick exploration without an IdP, the portal delegates credential validation to a Platform API sidecar. Users, bcrypt-hashed passwords, and `dp:*` scopes are defined in `configs/config-platform-api.toml` (copy from `configs/config-platform-api.toml.example`): +For quick exploration without an IdP, the portal delegates credential validation to a Platform API sidecar. Users, bcrypt-hashed passwords, and `dp:*` scopes are defined in `configs/config-platform-api.toml` (copy from `configs/config-platform-api-template.toml`): ```toml [[auth.file_based.users]] diff --git a/portals/developer-portal/configs/config-platform-api.toml.example b/portals/developer-portal/configs/config-platform-api-template.toml similarity index 100% rename from portals/developer-portal/configs/config-platform-api.toml.example rename to portals/developer-portal/configs/config-platform-api-template.toml diff --git a/portals/developer-portal/configs/config.toml b/portals/developer-portal/configs/config.toml index 2c640a818..9bb233ad3 100644 --- a/portals/developer-portal/configs/config.toml +++ b/portals/developer-portal/configs/config.toml @@ -27,6 +27,35 @@ # # Partial substitution works too: 'foo-{{ env "X" }}' resolves to "foo-bar" if # X=bar. See src/config/configLoader.js for the full implementation. +# +# Every key below except [security] carries a fallback matching its built-in +# default (src/config/configDefaults.js), so `npm start` with no env vars set +# behaves exactly as before — docker-compose.yaml / api-platform.env only need +# to set the env var to override it, never to define config.toml's own default. + +[server] +base_url = '{{ env "APIP_DP_SERVER_BASEURL" "http://localhost:3000" }}' + +[tls] +enabled = '{{ env "APIP_DP_TLS_ENABLED" "false" }}' +cert_file = '{{ env "APIP_DP_TLS_CERTFILE" "./resources/security/client-truststore.pem" }}' +key_file = '{{ env "APIP_DP_TLS_KEYFILE" "./resources/security/private-key.pem" }}' +ca_file = '{{ env "APIP_DP_TLS_CAFILE" "./resources/security/client-truststore.pem" }}' + +[logging] +console_only = '{{ env "APIP_DP_LOGGING_CONSOLEONLY" "true" }}' + +[database] +file = '{{ env "APIP_DP_DATABASE_FILE" "./devportal.db" }}' + +[demo] +enabled = '{{ env "APIP_DP_DEMO_ENABLED" "false" }}' + +[platform_api] +# Used for local auth credential validation when idp.client_id is empty. +base_url = '{{ env "APIP_DP_PLATFORMAPI_BASEURL" "" }}' +jwt_secret = '{{ env "APIP_DP_PLATFORMAPI_JWTSECRET" "" }}' +insecure = '{{ env "APIP_DP_PLATFORMAPI_INSECURE" "false" }}' [security] # Required — devportal fails closed at startup if either doesn't resolve to a diff --git a/portals/developer-portal/distribution/docker-compose.yaml b/portals/developer-portal/distribution/docker-compose.yaml index e9d010c52..256aaab85 100644 --- a/portals/developer-portal/distribution/docker-compose.yaml +++ b/portals/developer-portal/distribution/docker-compose.yaml @@ -23,22 +23,32 @@ # Quick start: # 1. (Optional) Edit configs/config-platform-api.toml to adjust users/org as needed # — ships with a default admin/admin user, ready to use as-is. -# 2. docker compose --env-file keys.env up -# 3. Open https://localhost:3000/default/views/default (accept the self-signed cert warning) -# Login with a user defined in configs/config-platform-api.toml (default: admin / admin) +# 2. Run ./setup.sh once — generates the required secrets (devportal encryption/ +# session keys, the Platform API's encryption key, a shared JWT signing key) +# into api-platform.env and a self-signed TLS certificate into +# resources/certificates. Since configs/config-platform-api.toml already +# exists (shipped by `make dist`), setup.sh leaves it as-is rather than +# generating a random admin password — delete it first if you want one. +# Safe to re-run — never overwrites an existing value. +# 3. docker compose up +# 4. Open https://localhost:3000/default/views/default (accept the self-signed cert warning) +# Login with a user defined in configs/config-platform-api.toml (default: admin / admin), +# or the admin credentials ./setup.sh printed if you deleted that file first. # -# Secrets (both are 32-byte keys — 64 hex chars / base64; generate with openssl rand -hex 32): -# APIP_CP_AUTH_JWT_SECRET_KEY — signs login JWTs; the devportal verifies with the same value. -# APIP_CP_ENCRYPTION_KEY — encrypts secrets/subscription tokens at rest. -# Both are REQUIRED. Put them in `keys.env` file and start -# with `docker compose --env-file keys.env up`; the Platform API reads them from -# config-platform-api.toml via {{ env "..." }} tokens and fails to start if either is missing. +# Secrets: +# The Platform API's encryption/JWT keys and devportal's encryption/session +# keys are all generated by ./setup.sh into api-platform.env (see above) and +# loaded into each container via env_file:. There is no ephemeral/demo +# fallback — both services fail closed at startup (clear error, non-zero +# exit) if a required secret is missing. # # Configuration: -# Edit configs/config.toml to customise devportal settings. +# Edit configs/config.toml to customise devportal settings. An env var only takes +# effect where config.toml explicitly references it via {{ env "APIP_DP_..." }} — +# see the syntax explanation at the top of that file. # Edit configs/config-platform-api.toml to customise Platform API settings (users, org, etc.). -# Every devportal config value can also be overridden via an APIP_DP_* env var. -# Create a .env file next to this file for persistent overrides. +# Create an api-platform.env file next to this file for persistent overrides +# (./setup.sh does this for you). # # Demo mode (on by default): # demo=false docker compose up @@ -52,12 +62,13 @@ # the PostgreSQL block that is commented out beneath it. # # TLS: -# A self-signed certificate is generated automatically on first start and stored -# in the certs_data volume. Your browser will show a security warning — click -# "Proceed" (or "Advanced → Proceed") to continue. -# To use your own certificate, replace the certs_data volume with a bind-mount: -# - ./my-certs:/app/certs:ro -# and ensure it contains server.crt and server.key. +# ./setup.sh generates a self-signed certificate into resources/certificates on +# the host, bind-mounted into the container. Your browser will show a security +# warning — click "Proceed" (or "Advanced → Proceed") to continue. +# devportal fails closed at startup if APIP_DP_TLS_ENABLED=true and no certificate +# is found — it never generates one itself (see docker-entrypoint.sh). +# To use your own certificate, put server.crt/server.key in resources/certificates, +# or point the volume mount below at a different directory. services: # ── Platform API (Go HTTPS backend — handles local auth) ──────────────────── @@ -77,17 +88,18 @@ services: - platform-api-certs:/certs platform-api: - image: ghcr.io/wso2/api-platform/platform-api:0.11.0 + image: ghcr.io/wso2/api-platform/platform-api:0.12.0 container_name: platform-api restart: unless-stopped command: ["-config", "/etc/platform-api/config-platform-api.toml"] - environment: - - APIP_CP_ENCRYPTION_KEY=${APIP_CP_ENCRYPTION_KEY:-} - - APIP_CP_AUTH_JWT_SECRET_KEY=${APIP_CP_AUTH_JWT_SECRET_KEY:-} + env_file: + - path: api-platform.env + required: true + format: raw volumes: - ./configs/config-platform-api.toml:/etc/platform-api/config-platform-api.toml:ro - platform-api-data:/app/data - - platform-api-certs:/app/data/certs + - ./resources/certificates:/etc/platform-api/tls:ro ports: - "9243:9243" depends_on: @@ -102,46 +114,13 @@ services: networks: - devportal-network - # ── PostgreSQL (optional) ──────────────────────────────────────────────────── - # Uncomment this service and replace the APIP_DP_DATABASE_* block in `devportal` below - # with the PostgreSQL block to switch from SQLite to PostgreSQL. - # - # postgres: - # image: postgres:16 - # environment: - # POSTGRES_USER: devportal - # POSTGRES_PASSWORD: devportal - # POSTGRES_DB: devportal - # volumes: - # - postgres_data:/var/lib/postgresql/data - # - ./database:/docker-entrypoint-initdb.d:ro - # healthcheck: - # test: ["CMD-SHELL", "pg_isready -U devportal -d devportal"] - # interval: 10s - # timeout: 5s - # retries: 10 - # start_period: 15s - # networks: - # - devportal-network - devportal: image: ghcr.io/wso2/api-platform/developer-portal:1.0.0-alpha2-SNAPSHOT + container_name: devportal + restart: unless-stopped depends_on: platform-api: condition: service_healthy - # Uncomment when using PostgreSQL: - # postgres: - # condition: service_healthy - ports: - - "3000:3000" # Developer Portal UI (HTTPS) - volumes: - # Mount config from the configs/ directory. - - ./configs/config.toml:/app/configs/config.toml:ro - # Persist the SQLite database file across container restarts. - - sqlite_data:/app/data - # Persist the auto-generated TLS certificate across container restarts. - # To use your own cert, replace with: - ./my-certs:/app/certs:ro - - certs_data:/app/certs environment: # ── Database: SQLite (default) ─────────────────────────────────────────── APIP_DP_DATABASE_FILE: /app/data/devportal.db @@ -154,7 +133,7 @@ services: # APIP_DP_DATABASE_PASSWORD: devportal # APIP_DP_DATABASE_NAME: devportal - # HTTPS — a self-signed cert is generated automatically on first start. + # HTTPS — certificate generated by ./setup.sh (see TLS note above). # To switch back to plain HTTP, set APIP_DP_TLS_ENABLED=false and APIP_DP_SERVER_BASEURL=http://localhost:3000. APIP_DP_TLS_ENABLED: "true" APIP_DP_SERVER_BASEURL: "https://localhost:3000" @@ -172,34 +151,29 @@ services: APIP_DP_LOGGING_CONSOLEONLY: "true" # Platform API — used for local auth credential validation. - # Set APIP_DP_PLATFORMAPI_JWTSECRET to the same value as APIP_CP_AUTH_JWT_SECRET_KEY so - # the devportal can verify Platform API JWTs locally (no per-request login call). APIP_DP_PLATFORMAPI_BASEURL: "https://platform-api:9243" - APIP_DP_PLATFORMAPI_JWTSECRET: ${APIP_CP_AUTH_JWT_SECRET_KEY:-} APIP_DP_PLATFORMAPI_INSECURE: "true" # Platform API uses a self-signed cert in this dev setup - - # AES-256-GCM key for encrypting subscription tokens and webhook secrets at rest. - # Generate once with: openssl rand -hex 32 - # Store in .env as: APIP_DP_SECURITY_ENCRYPTIONKEY=<64-char hex> - # Must remain stable across restarts — changing it makes existing encrypted data unreadable. - APIP_DP_SECURITY_ENCRYPTIONKEY: ${APIP_DP_SECURITY_ENCRYPTIONKEY:-} env_file: - - path: .env - required: false # Optional — create a .env file to add your own overrides + - path: api-platform.env + required: true + format: raw + volumes: + - ./configs/config.toml:/app/configs/config.toml:ro + # Persist the SQLite database file across container restarts. + - sqlite_data:/app/data + # Bind-mounted (not a named volume) so ./setup.sh can pre-seed the + # self-signed certificate from the host before the container ever starts. + - ./resources/certificates:/app/certs + ports: + - "3000:3000" networks: - devportal-network volumes: sqlite_data: driver: local - certs_data: - driver: local platform-api-data: driver: local - platform-api-certs: - driver: local - # postgres_data: # Uncomment when using PostgreSQL - # driver: local networks: devportal-network: diff --git a/portals/developer-portal/docker-compose.platform-api.yaml b/portals/developer-portal/docker-compose.platform-api.yaml index 80ad91fcb..6f1680d46 100644 --- a/portals/developer-portal/docker-compose.platform-api.yaml +++ b/portals/developer-portal/docker-compose.platform-api.yaml @@ -19,20 +19,20 @@ # Platform API only — for local development with npm start # # Quick start: -# 1. Build the Platform API image from source (../../platform-api): +# 1. Run ./setup.sh once — generates the required secrets into api-platform.env +# (also read directly by `npm start` below) and configs/config-platform-api.toml. +# 2. Build the Platform API image from source (../../platform-api): # cd ../../platform-api && make build # This is required — the published 0.10.0 image on ghcr.io predates the # /auth/login endpoint that the username/password login form here uses. -# 2. docker compose --env-file keys.env -f docker-compose.platform-api.yaml up -d -# 3. npm start -# 4. Open http://localhost:3000 -# Login: admin / admin +# 3. docker compose -f docker-compose.platform-api.yaml up -d +# 4. npm start +# 5. Open http://localhost:3000 +# Login with the admin credentials ./setup.sh printed. # -# APIP_CP_AUTH_JWT_SECRET_KEY (signs login sessions; share the same value with the devportal's -# APIP_DP_PLATFORMAPI_JWTSECRET) and APIP_CP_ENCRYPTION_KEY (encrypts data at rest) are both -# REQUIRED. Put them in a `keys.env` file and pass it with --env-file. Both are 32-byte keys -# (64 hex chars / base64 — openssl rand -hex 32); the Platform API reads them -# from config-platform-api.toml via {{ env "..." }} tokens and fails to start if either is missing. +# There is no ephemeral/demo fallback — the Platform API fails closed at startup +# if APIP_CP_ENCRYPTION_KEY or APIP_CP_AUTH_JWT_SECRET_KEY is missing (both are +# generated by ./setup.sh into api-platform.env and loaded here via env_file:). services: # One-shot init container: generates the TLS pair the platform-api HTTPS @@ -55,9 +55,10 @@ services: container_name: platform-api restart: unless-stopped command: ["-config", "/etc/platform-api/config-platform-api.toml"] - environment: - - APIP_CP_ENCRYPTION_KEY=${APIP_CP_ENCRYPTION_KEY:-} - - APIP_CP_AUTH_JWT_SECRET_KEY=${APIP_CP_AUTH_JWT_SECRET_KEY:-} + env_file: + - path: api-platform.env + required: false # APIP_CP_ENCRYPTION_KEY / APIP_CP_AUTH_JWT_SECRET_KEY / + # AUTH_FILE_BASED_USERS live here — run ./setup.sh to generate it. volumes: - ./configs/config-platform-api.toml:/etc/platform-api/config-platform-api.toml:ro - platform-api-data:/app/data diff --git a/portals/developer-portal/docker-compose.yaml b/portals/developer-portal/docker-compose.yaml index eebff7c93..3d30cbfd5 100644 --- a/portals/developer-portal/docker-compose.yaml +++ b/portals/developer-portal/docker-compose.yaml @@ -23,30 +23,33 @@ # diverge: uses `build: .` here vs pre-built GHCR image in distribution. # # Quick start: -# 1. Copy configs/config-platform-api.toml.example to configs/config-platform-api.toml -# and adjust users/org as needed. +# 1. Run ./setup.sh once — generates the required secrets (devportal encryption/ +# session keys, the Platform API's encryption key, a shared JWT signing key, +# and a random admin password) into api-platform.env, a self-signed TLS +# certificate into resources/certificates, and configs/config-platform-api.toml +# (gitignored). Safe to re-run — never overwrites an existing value. # 2. Build the Platform API image from source (../../platform-api): # cd ../../platform-api && make build # Required — the published 0.10.0 image on ghcr.io predates the /auth/login # endpoint that the username/password login form here uses. -# 3. docker compose --env-file keys.env up +# 3. docker compose up # 4. Open https://localhost:3000/default/views/default (accept the self-signed cert warning) -# Login with a user defined in configs/config-platform-api.toml (default: admin / admin) +# Login with the admin credentials ./setup.sh printed. # -# Secrets (both are 32-byte keys — 64 hex chars / base64; generate with openssl rand -hex 32): -# APIP_CP_AUTH_JWT_SECRET_KEY — signs login JWTs; the devportal verifies with the same value. -# APIP_CP_ENCRYPTION_KEY — encrypts secrets/subscription tokens at rest. -# Both are REQUIRED. Put them in `keys.env` file and start -# with `docker compose --env-file keys.env up`; the Platform API reads them from -# config-platform-api.toml via {{ env "..." }} tokens and fails to start if either is missing. +# Secrets: +# All required secrets are generated by ./setup.sh into api-platform.env (see +# above) and loaded into each container via env_file:. There is no ephemeral/ +# demo fallback — both services fail closed at startup (clear error, non-zero +# exit) if a required secret is missing. # # TLS: -# A self-signed certificate is generated automatically on first start and stored -# in the certs_data volume. Your browser will show a security warning — click -# "Proceed" (or "Advanced → Proceed") to continue. -# To use your own certificate, replace the certs_data volume with a bind-mount: -# - ./my-certs:/app/certs:ro -# and ensure it contains server.crt and server.key. +# ./setup.sh generates a self-signed certificate into resources/certificates on +# the host, bind-mounted into the container. Your browser will show a security +# warning — click "Proceed" (or "Advanced → Proceed") to continue. +# devportal fails closed at startup if APIP_DP_TLS_ENABLED=true and no certificate +# is found — it never generates one itself (see docker-entrypoint.sh). +# To use your own certificate, put server.crt/server.key in resources/certificates, +# or point the volume mount below at a different directory. # # Database: # SQLite is used by default — no external database required. @@ -54,10 +57,14 @@ # docker compose -f docker-compose.yaml -f docker-compose.postgres.yaml up # # Configuration: -# Edit configs/config.toml to customise devportal settings. -# Edit configs/config-platform-api.toml to customise Platform API settings (users, org, etc.). -# Every devportal config value can be overridden with an APIP_DP_* env var. -# Create a .env file at the project root for persistent overrides. +# Edit configs/config.toml to customise devportal settings. An env var only takes +# effect where config.toml explicitly references it via {{ env "APIP_DP_..." }} — +# see the syntax explanation at the top of that file. +# Edit configs/config-platform-api.toml to customise Platform API settings (org, +# users, etc.) — ./setup.sh generates it wired to api-platform.env; for a static, +# no-dependencies starting point instead, copy configs/config-platform-api-template.toml. +# Create an api-platform.env file at the project root for persistent overrides +# (./setup.sh does this for you). # # Demo mode (on by default): # demo=false docker compose up @@ -85,9 +92,10 @@ services: container_name: platform-api restart: unless-stopped command: ["-config", "/etc/platform-api/config-platform-api.toml"] - environment: - - APIP_CP_ENCRYPTION_KEY=${APIP_CP_ENCRYPTION_KEY:-} - - APIP_CP_AUTH_JWT_SECRET_KEY=${APIP_CP_AUTH_JWT_SECRET_KEY:-} + env_file: + - path: api-platform.env + required: false # APIP_CP_ENCRYPTION_KEY / APIP_CP_AUTH_JWT_SECRET_KEY / + # AUTH_FILE_BASED_USERS live here — run ./setup.sh to generate it. volumes: - ./configs/config-platform-api.toml:/etc/platform-api/config-platform-api.toml:ro - platform-api-data:/app/data @@ -117,9 +125,9 @@ services: - ./configs/config.toml:/app/configs/config.toml:ro # Persist the SQLite database across container restarts. - sqlite_data:/app/data - # Persist the auto-generated TLS certificate across container restarts. - # To use your own cert, replace with: - ./my-certs:/app/certs:ro - - certs_data:/app/certs + # Bind-mounted (not a named volume) so ./setup.sh can pre-seed the + # self-signed certificate from the host before the container ever starts. + - ./resources/certificates:/app/certs environment: # ── Database: SQLite (default) ─────────────────────────────────────────── APIP_DP_DATABASE_FILE: /app/data/devportal.db @@ -132,7 +140,7 @@ services: # APIP_DP_DATABASE_PASSWORD: devportal # APIP_DP_DATABASE_NAME: devportal - # HTTPS — a self-signed cert is generated automatically on first start. + # HTTPS — certificate generated by ./setup.sh (see TLS note above). # To switch back to plain HTTP, set APIP_DP_TLS_ENABLED=false and APIP_DP_SERVER_BASEURL=http://localhost:3000. APIP_DP_TLS_ENABLED: "true" APIP_DP_SERVER_BASEURL: "https://localhost:3000" @@ -150,23 +158,20 @@ services: APIP_DP_LOGGING_CONSOLEONLY: "true" # Platform API — used for local auth credential validation. - # Set APIP_DP_PLATFORMAPI_JWTSECRET to the same value as the Platform API's APIP_CP_AUTH_JWT_SECRET_KEY - # so the devportal can verify Platform API JWTs locally (no per-request login call). APIP_DP_PLATFORMAPI_BASEURL: "https://platform-api:9243" - APIP_DP_PLATFORMAPI_JWTSECRET: ${APIP_CP_AUTH_JWT_SECRET_KEY:-} APIP_DP_PLATFORMAPI_INSECURE: "true" # Platform API uses a self-signed cert in this dev setup - - # AES-256-GCM key for encrypting subscription tokens and webhook secrets at rest. - # Generate once with: openssl rand -hex 32 - # Store in .env as: APIP_DP_SECURITY_ENCRYPTIONKEY=<64-char hex> - # Must remain stable across restarts — changing it makes existing encrypted data unreadable. - APIP_DP_SECURITY_ENCRYPTIONKEY: ${APIP_DP_SECURITY_ENCRYPTIONKEY:-} env_file: - - path: .env - required: false # Optional — create a .env file to add your own overrides + # APIP_DP_SECURITY_ENCRYPTIONKEY, APIP_DP_SECURITY_SESSIONSECRET, and + # APIP_DP_PLATFORMAPI_JWTSECRET (same value as platform-api's own + # APIP_CP_AUTH_JWT_SECRET_KEY, under devportal's own env var name) live + # here — run ./setup.sh to generate it. Do NOT add these as `environment:` + # entries instead — compose's environment: always wins over env_file: for + # the same key, even when interpolated to empty, so that would silently + # blank them back out. + - path: api-platform.env + required: false volumes: sqlite_data: - certs_data: platform-api-data: platform-api-certs: diff --git a/portals/developer-portal/docker-entrypoint.sh b/portals/developer-portal/docker-entrypoint.sh index f27a87c87..dcc7782db 100644 --- a/portals/developer-portal/docker-entrypoint.sh +++ b/portals/developer-portal/docker-entrypoint.sh @@ -21,17 +21,17 @@ set -e CERT_DIR=/app/certs +TLS_ENABLED="${APIP_DP_TLS_ENABLED:-false}" -if [ ! -f "$CERT_DIR/server.crt" ] || [ ! -f "$CERT_DIR/server.key" ]; then - echo "[entrypoint] Generating self-signed TLS certificate..." - openssl req -x509 -newkey rsa:4096 \ - -keyout "$CERT_DIR/server.key" \ - -out "$CERT_DIR/server.crt" \ - -days 36500 -nodes \ - -subj "/C=US/ST=California/L=San Francisco/O=WSO2/OU=Developer Portal/CN=localhost" \ - -addext "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1" \ - 2>/dev/null - echo "[entrypoint] Self-signed certificate written to $CERT_DIR" +# Fail closed: certificates are generated once by ./setup.sh (host-side, into a +# bind-mounted directory), never here. Startup only checks that they exist — +# it never generates a fallback, matching every other required secret. +if [ "$TLS_ENABLED" = "true" ]; then + if [ ! -f "$CERT_DIR/server.crt" ] || [ ! -f "$CERT_DIR/server.key" ]; then + echo "[entrypoint] ERROR: TLS is enabled (APIP_DP_TLS_ENABLED=true) but no certificate was found at $CERT_DIR/server.crt / server.key. Run ./setup.sh first, or mount your own certificate at $CERT_DIR." >&2 + exit 1 + fi + echo "[entrypoint] TLS certificate found at $CERT_DIR" fi exec node src/server.js diff --git a/portals/developer-portal/docs/administer/manage-organizations.md b/portals/developer-portal/docs/administer/manage-organizations.md index 8aa50410a..dcfb70c29 100644 --- a/portals/developer-portal/docs/administer/manage-organizations.md +++ b/portals/developer-portal/docs/administer/manage-organizations.md @@ -104,10 +104,10 @@ For local development and first-time setup, the portal ships with a built-in use ### Configuration -Users and their scopes are defined in `configs/config-platform-api.toml`. Copy the example file to get started: +Users and their scopes are defined in `configs/config-platform-api.toml`. Copy the template file to get started: ```bash -cp configs/config-platform-api.toml.example configs/config-platform-api.toml +cp configs/config-platform-api-template.toml configs/config-platform-api.toml ``` Add or modify users in the `[[auth.file_based.users]]` sections: @@ -140,7 +140,7 @@ Every devportal REST API operation requires a specific `dp:*` scope. Users witho | API publisher | `dp:api_manage dp:api_content_manage dp:org_read dp:label_read` | | Developer / subscriber | `dp:api_read dp:app_read dp:app_write dp:subscription_read dp:subscription_write` | -See `configs/config-platform-api.toml.example` for the complete scope list used by the default admin user. +See `configs/config-platform-api-template.toml` for the complete scope list used by the default admin user. ### Session persistence and scripted access diff --git a/portals/developer-portal/docs/introduction/quick-start.md b/portals/developer-portal/docs/introduction/quick-start.md index 321e09f5b..8ba1ffb55 100644 --- a/portals/developer-portal/docs/introduction/quick-start.md +++ b/portals/developer-portal/docs/introduction/quick-start.md @@ -23,7 +23,7 @@ Copy both sample configuration files: ```bash mkdir -p configs cp configs/config.toml.example configs/config.toml -cp configs/config-platform-api.toml.example configs/config-platform-api.toml +cp configs/config-platform-api-template.toml configs/config-platform-api.toml ``` `config.toml` controls the Developer Portal itself. `config-platform-api.toml` configures the Platform API sidecar that validates login credentials and issues signed tokens. The default credentials in the example file are `admin` / `admin`. diff --git a/portals/developer-portal/it/configs/config-platform-api-it.toml b/portals/developer-portal/it/configs/config-platform-api-it.toml index 37c53d82a..48c52818c 100644 --- a/portals/developer-portal/it/configs/config-platform-api-it.toml +++ b/portals/developer-portal/it/configs/config-platform-api-it.toml @@ -10,7 +10,7 @@ # -------------------------------------------------------------------- # # Platform API file-based auth config for the developer-portal backend IT -# suite. Modeled on ../../configs/config-platform-api.toml.example, with three +# suite. Modeled on ../../configs/config-platform-api-template.toml, with three # accounts (admin / publisher / developer, password == username) instead of # just admin, so tests exercise real role-scoped authorization rather than the # devportal-it-test-key API key bypass. diff --git a/portals/developer-portal/it/docker-compose.test.yaml b/portals/developer-portal/it/docker-compose.test.yaml index 207606b73..7c2120415 100644 --- a/portals/developer-portal/it/docker-compose.test.yaml +++ b/portals/developer-portal/it/docker-compose.test.yaml @@ -124,7 +124,7 @@ services: DEVPORTAL_BASE_URL: "http://devportal:3000" # Real session login (config-platform-api-it.toml) — password == username # for all three, matching the admin/admin convention documented in - # configs/config-platform-api.toml.example. + # configs/config-platform-api-template.toml. DEVPORTAL_ORG_HANDLE: "default" DEVPORTAL_ADMIN_USERNAME: "admin" DEVPORTAL_ADMIN_PASSWORD: "admin" diff --git a/portals/developer-portal/setup.sh b/portals/developer-portal/setup.sh new file mode 100755 index 000000000..e1d26a674 --- /dev/null +++ b/portals/developer-portal/setup.sh @@ -0,0 +1,237 @@ +#!/bin/bash + +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# -------------------------------------------------------------------- + +# Quick-start setup for the Developer Portal. +# +# Generates the secrets required to run the portal locally (via Docker Compose, +# or natively with `npm start`): +# - devportal's own encryption/session keys (APIP_DP_SECURITY_*) +# - the Platform API's at-rest encryption key (APIP_CP_ENCRYPTION_KEY) +# - a shared JWT signing key for the Platform API (APIP_CP_AUTH_JWT_SECRET_KEY, +# written a second time as APIP_DP_PLATFORMAPI_JWTSECRET since devportal's +# config.toml references it under its own name) +# - a random admin password, bcrypt-hashed into AUTH_FILE_BASED_USERS +# - a self-signed TLS certificate for devportal +# +# This is a ONE-TIME step. It never runs as part of container startup — both +# services fail closed at startup if a required secret is missing, rather than +# silently generating or accepting a weaker one. Re-running this script is +# safe: it only fills in what's missing and never overwrites an existing value. +# +# Usage: +# ./setup.sh (from the project root — local dev) +# ./scripts/setup.sh (from the standalone distribution zip) +# docker compose up +# +# To rotate a value, delete it from api-platform.env (or delete +# resources/certificates for the TLS cert) and re-run this script. + +set -euo pipefail + +THIS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# This same script is copied verbatim into the distribution zip's scripts/ +# directory (see Makefile's dist target), so it can't assume its own directory +# is the project root — detect which layout is in play by locating +# docker-compose.yaml, which is always a direct sibling of the real root. +if [ -f "$THIS_DIR/docker-compose.yaml" ]; then + ROOT_DIR="$THIS_DIR" +elif [ -f "$THIS_DIR/../docker-compose.yaml" ]; then + ROOT_DIR="$(cd "$THIS_DIR/.." && pwd)" +else + echo "[setup] ERROR: could not find docker-compose.yaml next to this script or its parent directory." >&2 + echo "[setup] Run this as ./setup.sh from the project root, or ./scripts/setup.sh from the distribution zip." >&2 + exit 1 +fi +cd "$ROOT_DIR" + +ENV_FILE="$ROOT_DIR/api-platform.env" +DEVPORTAL_CERT_DIR="$ROOT_DIR/resources/certificates" +PLATFORM_API_CONFIG="$ROOT_DIR/configs/config-platform-api.toml" + +# Bind-mounted into a container running as a non-root UID: 644 (not 600) so the +# container user can read a file owned by the host user. Local single-user +# quick-start tradeoff — matches the perms the old auto-generated cert used. +CERT_FILE_MODE=644 + +ADMIN_USERNAME="admin" +GENERATED_PASSWORD="" + +log() { echo "[setup] $*"; } +fail() { echo "[setup] ERROR: $*" >&2; exit 1; } + +command -v openssl >/dev/null 2>&1 || fail "openssl is required but not found on PATH." +command -v docker >/dev/null 2>&1 || fail "docker is required but not found on PATH (used to hash the admin password)." + +touch "$ENV_FILE" + +# Sets KEY=VALUE in api-platform.env, but only if KEY isn't already present +# (idempotent — never overwrites a value the user or a previous run already set). +set_env_var() { + local key="$1" value="$2" + if grep -q "^${key}=" "$ENV_FILE" 2>/dev/null; then + log " - ${key} already set in api-platform.env, leaving as-is" + else + printf '%s=%s\n' "$key" "$value" >> "$ENV_FILE" + log " - ${key} generated" + fi +} + +get_env_var() { + grep "^${1}=" "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2- +} + +log "Generating devportal secrets into api-platform.env ..." +set_env_var "APIP_DP_SECURITY_ENCRYPTIONKEY" "$(openssl rand -hex 32)" +set_env_var "APIP_DP_SECURITY_SESSIONSECRET" "$(openssl rand -hex 32)" + +log "Generating Platform API encryption key into api-platform.env ..." +set_env_var "APIP_CP_ENCRYPTION_KEY" "$(openssl rand -hex 32)" + +log "Generating shared Platform API JWT signing key into api-platform.env ..." +# Written under both names it needs to reach: APIP_CP_AUTH_JWT_SECRET_KEY for the +# platform-api container's own config-platform-api.toml reference, +# APIP_DP_PLATFORMAPI_JWTSECRET for the devportal container's config.toml +# reference — same value, two names, since each config.toml reads a variable +# only under its own exact name. +if grep -q "^APIP_CP_AUTH_JWT_SECRET_KEY=" "$ENV_FILE" 2>/dev/null; then + log " - APIP_CP_AUTH_JWT_SECRET_KEY already set in api-platform.env, leaving as-is" +else + printf 'APIP_CP_AUTH_JWT_SECRET_KEY=%s\n' "$(openssl rand -hex 32)" >> "$ENV_FILE" + log " - APIP_CP_AUTH_JWT_SECRET_KEY generated" +fi +JWT_SECRET_KEY="$(get_env_var APIP_CP_AUTH_JWT_SECRET_KEY)" +set_env_var "APIP_DP_PLATFORMAPI_JWTSECRET" "$JWT_SECRET_KEY" + +log "Provisioning devportal TLS certificate ..." +mkdir -p "$DEVPORTAL_CERT_DIR" +if [ -f "$DEVPORTAL_CERT_DIR/server.crt" ] && [ -f "$DEVPORTAL_CERT_DIR/server.key" ]; then + log " - $DEVPORTAL_CERT_DIR already has a certificate, leaving as-is" +else + openssl req -x509 -newkey rsa:4096 \ + -keyout "$DEVPORTAL_CERT_DIR/server.key" \ + -out "$DEVPORTAL_CERT_DIR/server.crt" \ + -days 36500 -nodes \ + -subj "/C=US/ST=California/L=San Francisco/O=WSO2/OU=Developer Portal/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1" \ + 2>/dev/null + chmod "$CERT_FILE_MODE" "$DEVPORTAL_CERT_DIR/server.key" "$DEVPORTAL_CERT_DIR/server.crt" + log " - self-signed certificate generated at $DEVPORTAL_CERT_DIR" +fi + +log "Provisioning configs/config-platform-api.toml ..." +if [ -f "$PLATFORM_API_CONFIG" ]; then + log " - $PLATFORM_API_CONFIG already exists, leaving as-is" +else + mkdir -p "$ROOT_DIR/configs" + # Wired to api-platform.env via {{ env "..." }} tokens (never hardcode a + # secret here) — this is the file docker-compose.yaml bind-mounts into the + # platform-api container. It's gitignored: for a static, no-dependencies + # starting point instead (e.g. running platform-api directly, without + # ./setup.sh), copy configs/config-platform-api-template.toml instead. + cat > "$PLATFORM_API_CONFIG" <<'EOF' +# Platform API configuration for the Developer Portal. +# Generated by ./setup.sh — every secret below is a {{ env "..." }} token +# resolved from api-platform.env. Not tracked in git (see .gitignore). + +log_level = "INFO" # DEBUG | INFO | WARN | ERROR +port = "9243" + +encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' + +[database] +driver = "sqlite3" +path = "/app/data/platform-api-devportal.db" + +[auth.jwt] +enabled = true +issuer = "platform-api" +secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' + +[auth.idp] +enabled = false + +[auth.file_based] +enabled = true +users = '{{ env "AUTH_FILE_BASED_USERS" }}' + +[auth.file_based.organization] +id = "default" +display_name = "Default" +region = "us" + +[tls] +cert_dir = "/app/data/certs" + +[default_devportal] +enabled = false +EOF + log " - $PLATFORM_API_CONFIG generated" +fi + +# Full-access scopes for the seeded admin user — ap:* (platform-admin) plus every +# dp:*_manage scope so it can manage every Developer Portal resource area. +ADMIN_SCOPES="ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:webbroker_api:manage ap:websub_api:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:git:read ap:api_key:read dp:org_read dp:org_write dp:org_manage dp:org_delete dp:org_content_read dp:org_content_write dp:org_content_manage dp:org_content_delete dp:api_read dp:api_write dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:api_key_read dp:api_key_write dp:api_key_manage dp:api_key_revoke dp:api_flow_read dp:api_flow_write dp:api_flow_manage dp:api_flow_delete dp:api_workflow_read dp:api_workflow_create dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_read dp:app_write dp:app_manage dp:app_delete dp:app_key_write dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_read dp:subscription_write dp:subscription_manage dp:subscription_delete dp:sub_plan_read dp:sub_plan_write dp:sub_plan_manage dp:sub_plan_delete dp:idp_read dp:idp_write dp:idp_manage dp:idp_delete dp:view_read dp:view_write dp:view_manage dp:view_delete dp:km_read dp:km_write dp:km_manage dp:km_delete dp:label_read dp:label_write dp:label_manage dp:label_delete dp:provider_read dp:provider_write dp:provider_manage dp:provider_delete dp:event_read dp:delivery_manage dp:utility_write dp:utility_manage dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dev" + +log "Provisioning Platform API admin credentials ..." +if grep -q "^AUTH_FILE_BASED_USERS=" "$ENV_FILE" 2>/dev/null; then + log " - AUTH_FILE_BASED_USERS already set in api-platform.env, leaving admin credentials as-is" +else + GENERATED_PASSWORD="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | cut -c1-20)" + [ -n "$GENERATED_PASSWORD" ] || fail "failed to generate an admin password." + + # Use a throwaway httpd container for bcrypt hashing (htpasswd -B) rather than + # requiring apache2-utils to be installed on the host — docker is already a + # hard requirement for the rest of this workflow. + ADMIN_HASH="$(docker run --rm httpd:2.4-alpine htpasswd -nbBC 12 "$ADMIN_USERNAME" "$GENERATED_PASSWORD" | cut -d: -f2)" + [ -n "$ADMIN_HASH" ] || fail "failed to hash the admin password (is docker able to pull httpd:2.4-alpine?)." + + # Docker Compose's env_file: loader applies its own ${VAR} interpolation to + # file content, so a bcrypt hash's "$2y$12$..." gets silently mangled (each + # "$xyz" segment treated as an undefined variable reference and blanked out) + # unless every literal "$" is escaped as "$$" here. This is Compose-file + # syntax, not JSON syntax — platform-api itself receives the un-escaped hash + # once Compose substitutes "$$" back to "$" before injecting the container env. + ADMIN_HASH_ESCAPED="${ADMIN_HASH//\$/\$\$}" + + # Matches platform-api's own FileBasedUser JSON shape exactly (see + # platform-api/config/config.go: FileBasedUser / fileBasedUsersDecodeHook). + # configs/config-platform-api.toml references this via + # users = '{{ env "AUTH_FILE_BASED_USERS" }}' under [auth.file_based]. + AUTH_FILE_BASED_USERS_JSON="$(printf '[{"username":"%s","password_hash":"%s","scopes":"%s"}]' \ + "$ADMIN_USERNAME" "$ADMIN_HASH_ESCAPED" "$ADMIN_SCOPES")" + printf 'AUTH_FILE_BASED_USERS=%s\n' "$AUTH_FILE_BASED_USERS_JSON" >> "$ENV_FILE" + log " - AUTH_FILE_BASED_USERS generated in api-platform.env with a random admin password" +fi + +echo +log "Setup complete." +echo +if [ -n "$GENERATED_PASSWORD" ]; then + echo " ------------------------------------------------------------------" + echo " Admin login: ${ADMIN_USERNAME} / ${GENERATED_PASSWORD}" + echo " This password will not be shown again — copy it now." + echo " (It is stored, bcrypt-hashed, in api-platform.env's AUTH_FILE_BASED_USERS)" + echo " ------------------------------------------------------------------" + echo +fi +echo " Next step:" +echo " docker compose up" +echo From 815d56d931c8f60cf5498d8f5475436afe8577aa Mon Sep 17 00:00:00 2001 From: Lasantha Samarakoon Date: Thu, 16 Jul 2026 09:05:40 +0530 Subject: [PATCH 4/8] Restructured devportal distribution --- portals/developer-portal/.dockerignore | 3 +- portals/developer-portal/Dockerfile | 8 +- portals/developer-portal/Makefile | 3 +- portals/developer-portal/README.md | 36 ++-- .../configs/config-platform-api-template.toml | 39 ++-- .../developer-portal/distribution/README.md | 139 ++++++++++++++ .../distribution/docker-compose.yaml | 180 ------------------ .../docker-compose.override.yaml | 29 +++ portals/developer-portal/docker-compose.yaml | 164 +++------------- portals/developer-portal/docker-entrypoint.sh | 6 +- portals/developer-portal/setup.sh | 135 +++++++------ 11 files changed, 326 insertions(+), 416 deletions(-) create mode 100644 portals/developer-portal/distribution/README.md delete mode 100644 portals/developer-portal/distribution/docker-compose.yaml create mode 100644 portals/developer-portal/docker-compose.override.yaml diff --git a/portals/developer-portal/.dockerignore b/portals/developer-portal/.dockerignore index 54c40df1d..3381783d4 100644 --- a/portals/developer-portal/.dockerignore +++ b/portals/developer-portal/.dockerignore @@ -17,8 +17,7 @@ api-platform.env.* resources/certificates/ # ── Docker / distribution files ─────────────────────────────────── -docker-compose.yml -distribution/ +docker-compose*.yaml # ── Database init / seed scripts (volume-mounted into postgres) ─── artifacts/ diff --git a/portals/developer-portal/Dockerfile b/portals/developer-portal/Dockerfile index 7900023b6..bf14fa761 100644 --- a/portals/developer-portal/Dockerfile +++ b/portals/developer-portal/Dockerfile @@ -51,9 +51,11 @@ EXPOSE 8080 # Create a non-root user with UID 10001 to satisfy Checkov CKV_CHOREO_1 RUN groupadd -g 10001 appgroup && useradd -u 10001 -g appgroup -s /bin/bash -M appuser -# Pre-create the certs and logs directories owned by the non-root user so the -# entrypoint script can write self-signed certs, and the app can write log files. -RUN mkdir -p /app/certs /app/logs /app/data && chown -R 10001:10001 /app/certs /app/logs /app/data +# Pre-create the logs and data directories owned by the non-root user so the +# app can write log files and its database. The TLS certificate is bind-mounted +# read-only at /etc/devportal/tls (generated host-side by setup.sh) — never +# written to by the container. +RUN mkdir -p /app/logs /app/data && chown -R 10001:10001 /app/logs /app/data RUN chmod +x /app/docker-entrypoint.sh diff --git a/portals/developer-portal/Makefile b/portals/developer-portal/Makefile index f7683de59..a43d83f18 100644 --- a/portals/developer-portal/Makefile +++ b/portals/developer-portal/Makefile @@ -146,7 +146,8 @@ dist: clean-dist ## Build standalone developer portal distribution zip @cp configs/config-template.toml $(DIST_DIR)/configs/config-template.toml @cp configs/config-platform-api-template.toml $(DIST_DIR)/configs/config-platform-api.toml @cp configs/config-platform-api-template.toml $(DIST_DIR)/configs/config-platform-api-template.toml - @cp distribution/docker-compose.yaml $(DIST_DIR)/docker-compose.yaml + @cp docker-compose.yaml $(DIST_DIR)/docker-compose.yaml + @cp distribution/README.md $(DIST_DIR)/README.md @mkdir -p $(DIST_DIR)/scripts @cp setup.sh $(DIST_DIR)/scripts/setup.sh @chmod +x $(DIST_DIR)/scripts/setup.sh diff --git a/portals/developer-portal/README.md b/portals/developer-portal/README.md index db0b69ee8..94f40cecb 100644 --- a/portals/developer-portal/README.md +++ b/portals/developer-portal/README.md @@ -22,32 +22,24 @@ For end-user documentation, see [docs/](docs/). ## Quick Start (Docker Compose) -The fastest way to get the portal running — no local Node install required. - -### Build - -```bash -# Build the developer-portal Docker image from source -make build -``` +The fastest way to get the portal running — no local Node install required. Requires `openssl` and Docker (used by `./setup.sh` to bcrypt-hash the admin password). ### Run ```bash -mkdir -p configs && cp configs/config.toml.example configs/config.toml +./setup.sh docker compose up ``` -Then open **https://localhost:3000/default/views/default** +`./setup.sh` is a one-time step: it generates devportal's and the Platform API's encryption/JWT secrets, a self-signed TLS certificate, and an admin user into `api-platform.env` (git-ignored) and `configs/config-platform-api.toml` (also git-ignored — copy `configs/config-platform-api-template.toml` instead for a static, no-dependencies starting point). It prompts for an admin username/password interactively, or generates a random password if you press Enter; set `ADMIN_USERNAME`/`ADMIN_PASSWORD` env vars to skip the prompts (e.g. in CI). Safe to re-run — it only fills in what's missing and never overwrites an existing value; to build devportal from source instead of using the published image, run `docker compose up --build`. -> **Browser warning:** A self-signed TLS certificate is generated automatically on first start. Click **Advanced → Proceed** (Chrome) or **Accept the Risk** (Firefox) to continue. +Then open **https://localhost:3000/default/views/default** and log in with the admin credentials `./setup.sh` printed. -Default credentials: `admin` / `admin` (defined in `configs/config-platform-api.toml`) +> **Browser warning:** the TLS certificate is self-signed. Click **Advanced → Proceed** (Chrome) or **Accept the Risk** (Firefox) to continue. What happens automatically on first start: - The DB schema is applied and the database is initialised automatically - A default **default** org, view, labels, and subscription plans are seeded automatically on startup (controlled by `organization.default_name` in config) -- A self-signed TLS certificate is generated and stored in the `certs_data` Docker volume ### Test @@ -143,17 +135,13 @@ See [it/README.md](it/README.md) for the full list of test commands and suite de Use this for active development, custom IdP configuration, or when you prefer to run Node directly. -### 1. Create config file - -```bash -mkdir -p configs && cp configs/config.toml.example configs/config.toml -``` +### 1. Config file -`configs/config.toml` is your local config file (not committed to git). See [Configuration reference](#configuration-reference) below for all available settings. +`configs/config.toml` already ships with sensible defaults — edit it directly for custom settings. `configs/config-template.toml` is the full annotated reference of every available setting; see [Configuration reference](#configuration-reference) below. ### 2. Configure HTTP mode (optional) -Open `configs/config.toml` and confirm these are set (they are the defaults in `configs/config.toml.example`): +Open `configs/config.toml` and confirm these are set (they are the defaults): ```toml [tls] @@ -234,7 +222,7 @@ Open **http://localhost:3000/default/views/default** Seeds a set of sample APIs into the default organisation. Works with both the Docker Compose and `npm start` workflows. -Get a Bearer token first, then pass it via `DEVPORTAL_TOKEN`: +Get a Bearer token first, then pass it via `DEVPORTAL_TOKEN`. The examples below use `admin`/`admin` — substitute the credentials `./setup.sh` printed (or run `ADMIN_USERNAME=admin ADMIN_PASSWORD=admin ./setup.sh` for this fixed pair): **npm start (HTTP):** ```bash @@ -256,7 +244,7 @@ DEVPORTAL_URL=https://localhost:3000 DEVPORTAL_TOKEN=$TOKEN ./seeders/seed-apis. All settings live in `configs/config.toml`. Every setting can also be overridden with an `APIP_DP_*` environment variable. -The full annotated list of settings is in [`configs/config.toml.example`](configs/config.toml.example). +The full annotated list of settings is in [`configs/config-template.toml`](configs/config-template.toml). ### Local auth @@ -433,7 +421,7 @@ paths: ``` ```bash -# Get a Bearer token +# Get a Bearer token (substitute the credentials ./setup.sh printed) TOKEN=$(curl -sk -X POST "https://localhost:9243/api/portal/v0.9/auth/login" \ -d "username=admin&password=admin" | jq -r .token) @@ -457,5 +445,5 @@ Refresh the portal — the Ping API now appears in the catalog. Click it to view | Organization | `default` | | Default view | `default` | | Portal URL | `https://localhost:3000/default/views/default` | -| Admin credentials | `admin` / `admin` (local auth) | +| Admin credentials | printed by `./setup.sh` (local auth) | | Sample API | `Ping API` visible in the catalog | diff --git a/portals/developer-portal/configs/config-platform-api-template.toml b/portals/developer-portal/configs/config-platform-api-template.toml index b588ac7d9..3233326eb 100644 --- a/portals/developer-portal/configs/config-platform-api-template.toml +++ b/portals/developer-portal/configs/config-platform-api-template.toml @@ -15,9 +15,14 @@ # via the username/password login form. Credentials are validated by the # Platform API; the Developer Portal never sees the raw passwords. # -# Changing passwords: -# Generate a bcrypt hash: htpasswd -bnBC 12 "" | tr -d ':\n' -# Default credentials below: admin / admin +# Admin credentials: +# ./setup.sh generates APIP_CP_ADMIN_USERNAME / APIP_CP_ADMIN_PASSWORD_HASH into +# api-platform.env (prompting for a username/password, or randomly generating +# one) and the {{ env "..." }} tokens below resolve them. Setting up manually +# instead: generate a bcrypt hash with +# htpasswd -bnBC 12 "" | tr -d ':\n' and put both values in +# `keys.env` file — never hardcode raw values here or as literals in +# docker-compose.yaml. # # Secrets: # APIP_CP_AUTH_JWT_SECRET_KEY (signs login JWTs; the Developer Portal verifies them with @@ -34,7 +39,6 @@ # Server # --------------------------------------------------------------------------- log_level = "INFO" # DEBUG | INFO | WARN | ERROR -port = "9243" # --------------------------------------------------------------------------- # Encryption @@ -44,6 +48,16 @@ port = "9243" # encryption_key = '{{ file "/secrets/platform-api/encryption_key" }}' encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' +# --------------------------------------------------------------------------- +# HTTPS +# --------------------------------------------------------------------------- +[https] +enabled = true +port = "9243" +# Directory containing cert.pem and key.pem — must match the volume mount in +# docker-compose.yaml. There is no self-signed fallback. +cert_dir = "/etc/platform-api/tls" + # --------------------------------------------------------------------------- # Database # --------------------------------------------------------------------------- @@ -61,6 +75,9 @@ issuer = "platform-api" # (APIP_DP_PLATFORMAPI_JWTSECRET must match). Resolved from APIP_CP_AUTH_JWT_SECRET_KEY # from `keys.env` file; files preferred: secret_key = '{{ file "/secrets/platform-api/jwt_secret" }}' secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' +# Skip signature verification. MUST stay false — the server refuses to start +# when signature validation is disabled. +skip_validation = false [auth.idp] enabled = false @@ -73,21 +90,15 @@ id = "default" # Required: organization handle (URL-safe slug display_name = "Default" region = "us" -# Admin user — password: admin -# Change this before deploying to a shared environment. +# Admin user — username/password resolved from APIP_CP_ADMIN_USERNAME / +# APIP_CP_ADMIN_PASSWORD_HASH (see "Admin credentials" note above). # Scopes use the dp:* namespace understood by the Developer Portal REST API. # Add dp:*_manage scopes to grant full access to every resource area. [[auth.file_based.users]] -username = "admin" -password_hash = "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." +username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' +password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:webbroker_api:manage ap:websub_api:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:git:read ap:api_key:read dp:org_read dp:org_write dp:org_manage dp:org_delete dp:org_content_read dp:org_content_write dp:org_content_manage dp:org_content_delete dp:api_read dp:api_write dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:mcp_read dp:mcp_create dp:mcp_update dp:mcp_manage dp:mcp_delete dp:mcp_content_read dp:mcp_content_create dp:mcp_content_update dp:mcp_content_manage dp:mcp_content_delete dp:mcp_key_read dp:mcp_key_create dp:mcp_key_update dp:mcp_key_manage dp:mcp_key_revoke dp:api_key_read dp:api_key_write dp:api_key_manage dp:api_key_revoke dp:api_flow_read dp:api_flow_write dp:api_flow_manage dp:api_flow_delete dp:api_workflow_read dp:api_workflow_create dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_read dp:app_write dp:app_manage dp:app_delete dp:app_key_write dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_read dp:subscription_write dp:subscription_manage dp:subscription_delete dp:sub_plan_read dp:sub_plan_write dp:sub_plan_manage dp:sub_plan_delete dp:idp_read dp:idp_write dp:idp_manage dp:idp_delete dp:view_read dp:view_write dp:view_manage dp:view_delete dp:km_read dp:km_write dp:km_manage dp:km_delete dp:label_read dp:label_write dp:label_manage dp:label_delete dp:provider_read dp:provider_write dp:provider_manage dp:provider_delete dp:event_read dp:delivery_manage dp:utility_write dp:utility_manage dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dev" -# --------------------------------------------------------------------------- -# TLS -# --------------------------------------------------------------------------- -[tls] -cert_dir = "/app/data/certs" - # --------------------------------------------------------------------------- # DevPortal integration — disabled (devportal calls us, not the other way) # --------------------------------------------------------------------------- diff --git a/portals/developer-portal/distribution/README.md b/portals/developer-portal/distribution/README.md new file mode 100644 index 000000000..de398947d --- /dev/null +++ b/portals/developer-portal/distribution/README.md @@ -0,0 +1,139 @@ +# WSO2 API Platform — Developer Portal + +A standalone distribution of the Developer Portal and Platform API, orchestrated with Docker Compose. The Developer Portal is a Node.js web application for discovering and subscribing to APIs; the Platform API is its local-auth sidecar, validating username/password logins without requiring an external identity provider. + +## Contents + +``` +wso2apip-developer-portal-/ +├── README.md +├── docker-compose.yaml # Developer Portal + Platform API +├── scripts/ +│ └── setup.sh # One-time TLS + secrets provisioning +├── configs/ +│ ├── config.toml # Developer Portal active configuration +│ ├── config-template.toml # Developer Portal full configuration reference +│ ├── config-platform-api.toml # Platform API active configuration +│ └── config-platform-api-template.toml # Platform API full configuration reference +└── resources/ + └── developer-portal/ + └── db-scripts/ # Developer Portal PostgreSQL schema (reference copy) +``` + +## Prerequisites + +- Docker Engine 24+ +- Docker Compose v2 +- `openssl` and Docker (used by `setup.sh` to bcrypt-hash the admin password) + +## Quick Start + +Run the setup script once, from the distribution root, before the first start: + +```bash +./scripts/setup.sh +docker compose up -d +``` + +`setup.sh` generates everything the stack needs — nothing is auto-generated at runtime: + +| Output | Contents | +|---|---| +| `api-platform.env` (git-ignored) | `APIP_DP_SECURITY_ENCRYPTIONKEY` / `APIP_DP_SECURITY_SESSIONSECRET` (Developer Portal), `APIP_CP_ENCRYPTION_KEY` (Platform API at-rest encryption), `APIP_CP_AUTH_JWT_SECRET_KEY` + `APIP_DP_PLATFORMAPI_JWTSECRET` (same JWT signing key, one name per service), `APIP_CP_ADMIN_USERNAME` / `APIP_CP_ADMIN_PASSWORD_HASH` (bcrypt) | +| `resources/certificates/cert.pem` + `key.pem` | Self-signed TLS pair shared by both services | + +The admin password is generated and printed once by `setup.sh` — it is not stored anywhere; only its bcrypt hash lands in `api-platform.env`. Re-running `setup.sh` is safe: it only fills in what's missing and never overwrites an existing value — to rotate a value, delete it from `api-platform.env` (or delete `resources/certificates` for the TLS cert) and re-run. `ADMIN_USERNAME` / `ADMIN_PASSWORD` environment variables skip the interactive prompts (used by CI to pin known test credentials). + +Verify the Platform API is healthy: + +```bash +curl -fk https://localhost:9243/health +``` + +Open the Developer Portal in a browser at `https://localhost:3000/default/views/default` and log in with the admin credentials printed by `setup.sh`. + +> **Browser trust warning?** Both services use a self-signed TLS certificate by default. Click **Advanced → Proceed** to continue. See [Custom TLS Certificates](#custom-tls-certificates) to remove the warning permanently. + +## Exposed Ports + +| Port | Service | Description | +|------|---------|-------------| +| `3000` | Developer Portal | HTTPS — browser entry point | +| `9243` | Platform API | HTTPS — local-auth backend | + +## Configuration + +Edit `configs/config.toml` for Developer Portal settings and `configs/config-platform-api.toml` for Platform API settings. Both are read directly by the running containers — no rebuild required, just restart the affected service. + +Each config TOML writes secrets as `'{{ env "..." }}'` tokens, so a key can be set from the environment without editing the file — the token names the variable, by convention the key uppercased and prefixed with `APIP_DP_` (Developer Portal) or `APIP_CP_` (Platform API), e.g. `APIP_DP_TLS_ENABLED`, `APIP_CP_DATABASE_HOST`. A key with no token is not settable from the environment: uncomment or add it in the TOML first. To source a value from a mounted file instead — the right choice for secrets — swap the token for `'{{ file "/secrets/..." }}'`. Never write a secret as a raw literal in either file. + +Environment overrides go in `api-platform.env` (git-ignored; loaded into both containers via `env_file`, format `raw`, since the bcrypt password hash contains `$`, which must not be treated as a compose interpolation variable). + +### Developer Portal (`configs/config.toml`) + +| Setting | Description | Default | +|---------|-------------|---------| +| `[server].base_url` | Public URL shown in links and callbacks | `https://localhost:3000` | +| `[tls].enabled` | Terminate TLS in the portal itself (vs. behind a proxy) | `true` | +| `[database].type` | `sqlite` (default) or `postgres` | `sqlite` | +| `[idp].client_id` | Set to delegate login to an external OIDC provider — leave empty for local auth via `[platform_api]` | _(empty)_ | +| `[platform_api].base_url` | Address of the Platform API local-auth sidecar | `https://platform-api:9243` | +| `[organization].default_name` | Organization bootstrapped automatically on first start | `default` | +| `[demo].enabled` | Shows the "Seed sample APIs" action and onboarding overlay | `${demo:-true}` | + +### Platform API (`configs/config-platform-api.toml`) + +| Setting | Description | Default | +|---------|-------------|---------| +| `log_level` | Log level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` | +| `encryption_key` | Single 32-byte key (64 hex chars or base64) used for all at-rest encryption. Generate with `openssl rand -hex 32` | _(from `setup.sh`)_ | +| `[database].driver` | `sqlite3` or `postgres` | `sqlite3` | +| `[auth.jwt].secret_key` | 32-byte HMAC key signing login JWTs | _(from `setup.sh`)_ | +| `[auth.idp]` | JWKS-based IDP auth — disabled in quickstart mode | disabled | +| `[[auth.file_based.users]]` | Local user credentials — `username`/`password_hash` resolved from `setup.sh`'s env vars, `scopes` is a plain literal | admin, generated by `setup.sh` | +| `[tls].cert_dir` | Listener certificate directory | `/etc/platform-api/tls` | + +See `configs/config-template.toml` and `configs/config-platform-api-template.toml` for a fully-commented reference of every available setting. + +## Authentication Modes + +### File-based (default) + +The admin user is generated by `setup.sh` (see [Quick Start](#quick-start)). To set your own password instead, generate a new bcrypt hash: + +```bash +htpasswd -bnBC 12 "" NEW_PASSWORD | tr -d ':\n' +``` + +Put the hash in `api-platform.env` as `APIP_CP_ADMIN_PASSWORD_HASH` (and the username as `APIP_CP_ADMIN_USERNAME`) before starting. + +### OIDC (production) + +To delegate login to an external OIDC-compliant provider instead of file-based auth: + +1. Register an OIDC application in your IDP with redirect URL `https:////callback`, and enable the **Authorization Code** grant. +2. In `configs/config.toml`, fill in the `[idp]` block — `client_id`, `client_secret`, `issuer`, `authorization_url`, `token_url`, `jwks_url`, `callback_url`, etc. Setting `client_id` is what switches the portal from local auth to OIDC. +3. Adjust `[idp.claims]` and `[idp.roles]` to match what your IDP puts in the issued token. + +See `configs/config-template.toml` for the full, per-field reference. + +## Custom TLS Certificates + +`resources/certificates/` holds the TLS pair shared by both services — `cert.pem` and `key.pem`, generated by `setup.sh`. This one directory is mounted read-only into both containers at their `/etc//tls` path. To remove the browser trust warning, replace both files with a certificate from your own CA (same file names), then restart: + +```bash +docker compose up -d +``` + +## Database + +The Developer Portal uses **SQLite** by default (data persisted in a Docker volume) — tables are created automatically on first start. To switch to PostgreSQL, update `configs/config.toml`'s `[database]` block with `type = "postgres"` and your connection details. + +`resources/developer-portal/db-scripts/` contains a reference copy of the Developer Portal's PostgreSQL schema and query files (also bundled inside the image) — provided for inspection; no manual SQL execution is required. + +## License + +Copyright (c) 2026, WSO2 LLC. (https://wso2.com) + +Licensed under the Apache License, Version 2.0. You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 diff --git a/portals/developer-portal/distribution/docker-compose.yaml b/portals/developer-portal/distribution/docker-compose.yaml deleted file mode 100644 index 256aaab85..000000000 --- a/portals/developer-portal/distribution/docker-compose.yaml +++ /dev/null @@ -1,180 +0,0 @@ -# -------------------------------------------------------------------- -# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). -# -# WSO2 LLC. licenses this file to you under the Apache License, -# Version 2.0 (the "License"); you may not use this file except -# in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# -------------------------------------------------------------------- - -# Developer Portal — standalone distribution variant (assembled by `make dist`). -# Uses pre-built images from GHCR. For the build-from-source variant see -# the docker-compose.yml one level up. -# -# Quick start: -# 1. (Optional) Edit configs/config-platform-api.toml to adjust users/org as needed -# — ships with a default admin/admin user, ready to use as-is. -# 2. Run ./setup.sh once — generates the required secrets (devportal encryption/ -# session keys, the Platform API's encryption key, a shared JWT signing key) -# into api-platform.env and a self-signed TLS certificate into -# resources/certificates. Since configs/config-platform-api.toml already -# exists (shipped by `make dist`), setup.sh leaves it as-is rather than -# generating a random admin password — delete it first if you want one. -# Safe to re-run — never overwrites an existing value. -# 3. docker compose up -# 4. Open https://localhost:3000/default/views/default (accept the self-signed cert warning) -# Login with a user defined in configs/config-platform-api.toml (default: admin / admin), -# or the admin credentials ./setup.sh printed if you deleted that file first. -# -# Secrets: -# The Platform API's encryption/JWT keys and devportal's encryption/session -# keys are all generated by ./setup.sh into api-platform.env (see above) and -# loaded into each container via env_file:. There is no ephemeral/demo -# fallback — both services fail closed at startup (clear error, non-zero -# exit) if a required secret is missing. -# -# Configuration: -# Edit configs/config.toml to customise devportal settings. An env var only takes -# effect where config.toml explicitly references it via {{ env "APIP_DP_..." }} — -# see the syntax explanation at the top of that file. -# Edit configs/config-platform-api.toml to customise Platform API settings (users, org, etc.). -# Create an api-platform.env file next to this file for persistent overrides -# (./setup.sh does this for you). -# -# Demo mode (on by default): -# demo=false docker compose up -# Disables the "Seed sample APIs" button in Settings and the onboarding prompt. -# -# Database: -# SQLite is used by default — no external database required. -# To switch to PostgreSQL: -# 1. Uncomment the `postgres` service below. -# 2. Replace the APIP_DP_DATABASE_* SQLite block in the `devportal` service with -# the PostgreSQL block that is commented out beneath it. -# -# TLS: -# ./setup.sh generates a self-signed certificate into resources/certificates on -# the host, bind-mounted into the container. Your browser will show a security -# warning — click "Proceed" (or "Advanced → Proceed") to continue. -# devportal fails closed at startup if APIP_DP_TLS_ENABLED=true and no certificate -# is found — it never generates one itself (see docker-entrypoint.sh). -# To use your own certificate, put server.crt/server.key in resources/certificates, -# or point the volume mount below at a different directory. - -services: - # ── Platform API (Go HTTPS backend — handles local auth) ──────────────────── - # One-shot init container: generates the TLS pair the platform-api HTTPS - # listener requires (the server no longer generates a self-signed fallback). - platform-api-certgen: - image: alpine/openssl - 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" - volumes: - - platform-api-certs:/certs - - platform-api: - image: ghcr.io/wso2/api-platform/platform-api:0.12.0 - container_name: platform-api - restart: unless-stopped - command: ["-config", "/etc/platform-api/config-platform-api.toml"] - env_file: - - path: api-platform.env - required: true - format: raw - volumes: - - ./configs/config-platform-api.toml:/etc/platform-api/config-platform-api.toml:ro - - platform-api-data:/app/data - - ./resources/certificates:/etc/platform-api/tls:ro - ports: - - "9243:9243" - depends_on: - platform-api-certgen: - condition: service_completed_successfully - healthcheck: - test: ["CMD", "curl", "-fk", "https://localhost:9243/health"] - interval: 30s - timeout: 5s - start_period: 20s - retries: 3 - networks: - - devportal-network - - devportal: - image: ghcr.io/wso2/api-platform/developer-portal:1.0.0-alpha2-SNAPSHOT - container_name: devportal - restart: unless-stopped - depends_on: - platform-api: - condition: service_healthy - environment: - # ── Database: SQLite (default) ─────────────────────────────────────────── - APIP_DP_DATABASE_FILE: /app/data/devportal.db - - # ── Database: PostgreSQL (optional) ───────────────────────────────────── - # APIP_DP_DATABASE_TYPE: postgres - # APIP_DP_DATABASE_HOST: postgres - # APIP_DP_DATABASE_PORT: 5432 - # APIP_DP_DATABASE_USERNAME: devportal - # APIP_DP_DATABASE_PASSWORD: devportal - # APIP_DP_DATABASE_NAME: devportal - - # HTTPS — certificate generated by ./setup.sh (see TLS note above). - # To switch back to plain HTTP, set APIP_DP_TLS_ENABLED=false and APIP_DP_SERVER_BASEURL=http://localhost:3000. - APIP_DP_TLS_ENABLED: "true" - APIP_DP_SERVER_BASEURL: "https://localhost:3000" - APIP_DP_TLS_CERTFILE: "/app/certs/server.crt" - APIP_DP_TLS_KEYFILE: "/app/certs/server.key" - APIP_DP_TLS_CAFILE: "/app/certs/server.crt" - - # Demo mode — on by default. When enabled, the Settings > Manage APIs page - # shows a "Seed sample APIs" button and admins get a one-time onboarding - # prompt to deploy sample APIs/MCPs. Disable with: - # demo=false docker compose up - APIP_DP_DEMO_ENABLED: "${demo:-true}" - - # Log to console only (no rotating log files) - APIP_DP_LOGGING_CONSOLEONLY: "true" - - # Platform API — used for local auth credential validation. - APIP_DP_PLATFORMAPI_BASEURL: "https://platform-api:9243" - APIP_DP_PLATFORMAPI_INSECURE: "true" # Platform API uses a self-signed cert in this dev setup - env_file: - - path: api-platform.env - required: true - format: raw - volumes: - - ./configs/config.toml:/app/configs/config.toml:ro - # Persist the SQLite database file across container restarts. - - sqlite_data:/app/data - # Bind-mounted (not a named volume) so ./setup.sh can pre-seed the - # self-signed certificate from the host before the container ever starts. - - ./resources/certificates:/app/certs - ports: - - "3000:3000" - networks: - - devportal-network - -volumes: - sqlite_data: - driver: local - platform-api-data: - driver: local - -networks: - devportal-network: - driver: bridge diff --git a/portals/developer-portal/docker-compose.override.yaml b/portals/developer-portal/docker-compose.override.yaml new file mode 100644 index 000000000..1e3d6700c --- /dev/null +++ b/portals/developer-portal/docker-compose.override.yaml @@ -0,0 +1,29 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# -------------------------------------------------------------------- + +# Local source-build override — auto-merged by `docker compose up` alongside +# docker-compose.yaml (Compose's default file list). Never copied into the +# distribution zip (see Makefile's dist target), so the shipped zip always +# runs the published devportal image untouched. +# +# To run against the published image instead of building from source, pass +# `docker compose -f docker-compose.yaml up` to skip this file. + +services: + devportal: + build: . diff --git a/portals/developer-portal/docker-compose.yaml b/portals/developer-portal/docker-compose.yaml index 3d30cbfd5..add0715e7 100644 --- a/portals/developer-portal/docker-compose.yaml +++ b/portals/developer-portal/docker-compose.yaml @@ -3,175 +3,73 @@ # # WSO2 LLC. licenses this file to you under the Apache License, # Version 2.0 (the "License"); you may not use this file except -# in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. +# in compliance with the License. You may obtain a copy of the +# License at http://www.apache.org/licenses/LICENSE-2.0 # -------------------------------------------------------------------- - -# Developer Portal — Docker Compose (development / build-from-source variant) -# -# NOTE: distribution/docker-compose.yaml ships the standalone variant (pre-built image) — keep -# devportal service config in sync there too. Documented differences that should -# diverge: uses `build: .` here vs pre-built GHCR image in distribution. -# -# Quick start: -# 1. Run ./setup.sh once — generates the required secrets (devportal encryption/ -# session keys, the Platform API's encryption key, a shared JWT signing key, -# and a random admin password) into api-platform.env, a self-signed TLS -# certificate into resources/certificates, and configs/config-platform-api.toml -# (gitignored). Safe to re-run — never overwrites an existing value. -# 2. Build the Platform API image from source (../../platform-api): -# cd ../../platform-api && make build -# Required — the published 0.10.0 image on ghcr.io predates the /auth/login -# endpoint that the username/password login form here uses. -# 3. docker compose up -# 4. Open https://localhost:3000/default/views/default (accept the self-signed cert warning) -# Login with the admin credentials ./setup.sh printed. -# -# Secrets: -# All required secrets are generated by ./setup.sh into api-platform.env (see -# above) and loaded into each container via env_file:. There is no ephemeral/ -# demo fallback — both services fail closed at startup (clear error, non-zero -# exit) if a required secret is missing. -# -# TLS: -# ./setup.sh generates a self-signed certificate into resources/certificates on -# the host, bind-mounted into the container. Your browser will show a security -# warning — click "Proceed" (or "Advanced → Proceed") to continue. -# devportal fails closed at startup if APIP_DP_TLS_ENABLED=true and no certificate -# is found — it never generates one itself (see docker-entrypoint.sh). -# To use your own certificate, put server.crt/server.key in resources/certificates, -# or point the volume mount below at a different directory. -# -# Database: -# SQLite is used by default — no external database required. -# To switch to PostgreSQL, run with the postgres override file: -# docker compose -f docker-compose.yaml -f docker-compose.postgres.yaml up -# -# Configuration: -# Edit configs/config.toml to customise devportal settings. An env var only takes -# effect where config.toml explicitly references it via {{ env "APIP_DP_..." }} — -# see the syntax explanation at the top of that file. -# Edit configs/config-platform-api.toml to customise Platform API settings (org, -# users, etc.) — ./setup.sh generates it wired to api-platform.env; for a static, -# no-dependencies starting point instead, copy configs/config-platform-api-template.toml. -# Create an api-platform.env file at the project root for persistent overrides -# (./setup.sh does this for you). -# -# Demo mode (on by default): -# demo=false docker compose up -# Disables the "Seed sample APIs" button in Settings and the onboarding prompt. +# Developer Portal + Platform API - standalone release compose file. See README.md. services: - # ── Platform API (Go HTTPS backend — handles local auth) ──────────────────── - # One-shot init container: generates the TLS pair the platform-api HTTPS - # listener requires (the server no longer generates a self-signed fallback). - platform-api-certgen: - image: alpine/openssl - 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" - volumes: - - platform-api-certs:/certs - platform-api: - image: ghcr.io/wso2/api-platform/platform-api:0.11.0 + image: ghcr.io/wso2/api-platform/platform-api:0.12.0 container_name: platform-api restart: unless-stopped command: ["-config", "/etc/platform-api/config-platform-api.toml"] env_file: - path: api-platform.env - required: false # APIP_CP_ENCRYPTION_KEY / APIP_CP_AUTH_JWT_SECRET_KEY / - # AUTH_FILE_BASED_USERS live here — run ./setup.sh to generate it. + required: true + format: raw volumes: - ./configs/config-platform-api.toml:/etc/platform-api/config-platform-api.toml:ro - platform-api-data:/app/data - - platform-api-certs:/app/data/certs + - ./resources/certificates:/etc/platform-api/tls:ro ports: - "9243:9243" - depends_on: - platform-api-certgen: - condition: service_completed_successfully healthcheck: test: ["CMD", "curl", "-fk", "https://localhost:9243/health"] interval: 30s timeout: 5s start_period: 20s retries: 3 + networks: + - devportal-network devportal: - build: . image: ghcr.io/wso2/api-platform/developer-portal:1.0.0-alpha2-SNAPSHOT + container_name: devportal + restart: unless-stopped depends_on: platform-api: condition: service_healthy - ports: - - "3000:3000" - volumes: - # Mount config from the configs/ directory. - - ./configs/config.toml:/app/configs/config.toml:ro - # Persist the SQLite database across container restarts. - - sqlite_data:/app/data - # Bind-mounted (not a named volume) so ./setup.sh can pre-seed the - # self-signed certificate from the host before the container ever starts. - - ./resources/certificates:/app/certs environment: - # ── Database: SQLite (default) ─────────────────────────────────────────── APIP_DP_DATABASE_FILE: /app/data/devportal.db - - # ── Database: PostgreSQL (optional) ───────────────────────────────────── - # APIP_DP_DATABASE_TYPE: postgres - # APIP_DP_DATABASE_HOST: postgres - # APIP_DP_DATABASE_PORT: 5432 - # APIP_DP_DATABASE_USERNAME: devportal - # APIP_DP_DATABASE_PASSWORD: devportal - # APIP_DP_DATABASE_NAME: devportal - - # HTTPS — certificate generated by ./setup.sh (see TLS note above). - # To switch back to plain HTTP, set APIP_DP_TLS_ENABLED=false and APIP_DP_SERVER_BASEURL=http://localhost:3000. APIP_DP_TLS_ENABLED: "true" APIP_DP_SERVER_BASEURL: "https://localhost:3000" - APIP_DP_TLS_CERTFILE: "/app/certs/server.crt" - APIP_DP_TLS_KEYFILE: "/app/certs/server.key" - APIP_DP_TLS_CAFILE: "/app/certs/server.crt" - - # Demo mode — on by default. When enabled, the Settings > Manage APIs page - # shows a "Seed sample APIs" button and admins get a one-time onboarding - # prompt to deploy sample APIs/MCPs. Disable with: - # demo=false docker compose up + APIP_DP_TLS_CERTFILE: "/etc/devportal/tls/cert.pem" + APIP_DP_TLS_KEYFILE: "/etc/devportal/tls/key.pem" + APIP_DP_TLS_CAFILE: "/etc/devportal/tls/cert.pem" APIP_DP_DEMO_ENABLED: "${demo:-true}" - - # Log to console only (no rotating log files) APIP_DP_LOGGING_CONSOLEONLY: "true" - - # Platform API — used for local auth credential validation. APIP_DP_PLATFORMAPI_BASEURL: "https://platform-api:9243" - APIP_DP_PLATFORMAPI_INSECURE: "true" # Platform API uses a self-signed cert in this dev setup + APIP_DP_PLATFORMAPI_INSECURE: "true" env_file: - # APIP_DP_SECURITY_ENCRYPTIONKEY, APIP_DP_SECURITY_SESSIONSECRET, and - # APIP_DP_PLATFORMAPI_JWTSECRET (same value as platform-api's own - # APIP_CP_AUTH_JWT_SECRET_KEY, under devportal's own env var name) live - # here — run ./setup.sh to generate it. Do NOT add these as `environment:` - # entries instead — compose's environment: always wins over env_file: for - # the same key, even when interpolated to empty, so that would silently - # blank them back out. - path: api-platform.env - required: false + required: true + format: raw + volumes: + - ./configs/config.toml:/app/configs/config.toml:ro + - sqlite_data:/app/data + - ./resources/certificates:/etc/devportal/tls:ro + ports: + - "3000:3000" + networks: + - devportal-network volumes: sqlite_data: + driver: local platform-api-data: - platform-api-certs: + driver: local + +networks: + devportal-network: + driver: bridge diff --git a/portals/developer-portal/docker-entrypoint.sh b/portals/developer-portal/docker-entrypoint.sh index dcc7782db..51b70307c 100644 --- a/portals/developer-portal/docker-entrypoint.sh +++ b/portals/developer-portal/docker-entrypoint.sh @@ -20,15 +20,15 @@ set -e -CERT_DIR=/app/certs +CERT_DIR=/etc/devportal/tls TLS_ENABLED="${APIP_DP_TLS_ENABLED:-false}" # Fail closed: certificates are generated once by ./setup.sh (host-side, into a # bind-mounted directory), never here. Startup only checks that they exist — # it never generates a fallback, matching every other required secret. if [ "$TLS_ENABLED" = "true" ]; then - if [ ! -f "$CERT_DIR/server.crt" ] || [ ! -f "$CERT_DIR/server.key" ]; then - echo "[entrypoint] ERROR: TLS is enabled (APIP_DP_TLS_ENABLED=true) but no certificate was found at $CERT_DIR/server.crt / server.key. Run ./setup.sh first, or mount your own certificate at $CERT_DIR." >&2 + if [ ! -f "$CERT_DIR/cert.pem" ] || [ ! -f "$CERT_DIR/key.pem" ]; then + echo "[entrypoint] ERROR: TLS is enabled (APIP_DP_TLS_ENABLED=true) but no certificate was found at $CERT_DIR/cert.pem / key.pem. Run ./setup.sh first, or mount your own certificate at $CERT_DIR." >&2 exit 1 fi echo "[entrypoint] TLS certificate found at $CERT_DIR" diff --git a/portals/developer-portal/setup.sh b/portals/developer-portal/setup.sh index e1d26a674..8f5d6ee99 100755 --- a/portals/developer-portal/setup.sh +++ b/portals/developer-portal/setup.sh @@ -20,15 +20,15 @@ # Quick-start setup for the Developer Portal. # -# Generates the secrets required to run the portal locally (via Docker Compose, -# or natively with `npm start`): +# Provisions, in order: +# - a self-signed TLS certificate for devportal # - devportal's own encryption/session keys (APIP_DP_SECURITY_*) # - the Platform API's at-rest encryption key (APIP_CP_ENCRYPTION_KEY) # - a shared JWT signing key for the Platform API (APIP_CP_AUTH_JWT_SECRET_KEY, # written a second time as APIP_DP_PLATFORMAPI_JWTSECRET since devportal's # config.toml references it under its own name) -# - a random admin password, bcrypt-hashed into AUTH_FILE_BASED_USERS -# - a self-signed TLS certificate for devportal +# - an admin username/password (prompted interactively — see below), bcrypt-hashed +# into APIP_CP_ADMIN_USERNAME / APIP_CP_ADMIN_PASSWORD_HASH # # This is a ONE-TIME step. It never runs as part of container startup — both # services fail closed at startup if a required secret is missing, rather than @@ -40,6 +40,11 @@ # ./scripts/setup.sh (from the standalone distribution zip) # docker compose up # +# ADMIN_USERNAME / ADMIN_PASSWORD environment variables skip the interactive +# prompts and pin the credentials (used by CI). If ADMIN_PASSWORD is left +# unset at an interactive terminal, pressing Enter at the prompt generates +# a random one, printed once at the end. +# # To rotate a value, delete it from api-platform.env (or delete # resources/certificates for the TLS cert) and re-run this script. @@ -71,9 +76,6 @@ PLATFORM_API_CONFIG="$ROOT_DIR/configs/config-platform-api.toml" # quick-start tradeoff — matches the perms the old auto-generated cert used. CERT_FILE_MODE=644 -ADMIN_USERNAME="admin" -GENERATED_PASSWORD="" - log() { echo "[setup] $*"; } fail() { echo "[setup] ERROR: $*" >&2; exit 1; } @@ -98,6 +100,26 @@ get_env_var() { grep "^${1}=" "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2- } +log "Provisioning TLS certificate ..." +mkdir -p "$DEVPORTAL_CERT_DIR" +# Shared by both containers (mounted read-only into each at its own /etc//tls +# path) — platform-api's HTTPSListener hardcodes the filenames cert.pem/key.pem within +# its cert_dir, so this pair must use those exact names regardless of what devportal's +# own (fully configurable) APIP_DP_TLS_CERTFILE/KEYFILE point at. +if [ -f "$DEVPORTAL_CERT_DIR/cert.pem" ] && [ -f "$DEVPORTAL_CERT_DIR/key.pem" ]; then + log " - $DEVPORTAL_CERT_DIR already has a certificate, leaving as-is" +else + openssl req -x509 -newkey rsa:4096 \ + -keyout "$DEVPORTAL_CERT_DIR/key.pem" \ + -out "$DEVPORTAL_CERT_DIR/cert.pem" \ + -days 36500 -nodes \ + -subj "/C=US/ST=California/L=San Francisco/O=WSO2/OU=Developer Portal/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,DNS:*.localhost,DNS:platform-api,DNS:devportal,IP:127.0.0.1" \ + 2>/dev/null + chmod "$CERT_FILE_MODE" "$DEVPORTAL_CERT_DIR/key.pem" "$DEVPORTAL_CERT_DIR/cert.pem" + log " - self-signed certificate generated at $DEVPORTAL_CERT_DIR" +fi + log "Generating devportal secrets into api-platform.env ..." set_env_var "APIP_DP_SECURITY_ENCRYPTIONKEY" "$(openssl rand -hex 32)" set_env_var "APIP_DP_SECURITY_SESSIONSECRET" "$(openssl rand -hex 32)" @@ -120,21 +142,10 @@ fi JWT_SECRET_KEY="$(get_env_var APIP_CP_AUTH_JWT_SECRET_KEY)" set_env_var "APIP_DP_PLATFORMAPI_JWTSECRET" "$JWT_SECRET_KEY" -log "Provisioning devportal TLS certificate ..." -mkdir -p "$DEVPORTAL_CERT_DIR" -if [ -f "$DEVPORTAL_CERT_DIR/server.crt" ] && [ -f "$DEVPORTAL_CERT_DIR/server.key" ]; then - log " - $DEVPORTAL_CERT_DIR already has a certificate, leaving as-is" -else - openssl req -x509 -newkey rsa:4096 \ - -keyout "$DEVPORTAL_CERT_DIR/server.key" \ - -out "$DEVPORTAL_CERT_DIR/server.crt" \ - -days 36500 -nodes \ - -subj "/C=US/ST=California/L=San Francisco/O=WSO2/OU=Developer Portal/CN=localhost" \ - -addext "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1" \ - 2>/dev/null - chmod "$CERT_FILE_MODE" "$DEVPORTAL_CERT_DIR/server.key" "$DEVPORTAL_CERT_DIR/server.crt" - log " - self-signed certificate generated at $DEVPORTAL_CERT_DIR" -fi +# Full-access scopes for the seeded admin user — ap:* (platform-admin) plus every +# dp:*_manage scope so it can manage every Developer Portal resource area. A plain +# literal in config-platform-api.toml (never templated), since it carries no secret. +ADMIN_SCOPES="ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:webbroker_api:manage ap:websub_api:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:git:read ap:api_key:read dp:org_read dp:org_write dp:org_manage dp:org_delete dp:org_content_read dp:org_content_write dp:org_content_manage dp:org_content_delete dp:api_read dp:api_write dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:api_key_read dp:api_key_write dp:api_key_manage dp:api_key_revoke dp:api_flow_read dp:api_flow_write dp:api_flow_manage dp:api_flow_delete dp:api_workflow_read dp:api_workflow_create dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_read dp:app_write dp:app_manage dp:app_delete dp:app_key_write dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_read dp:subscription_write dp:subscription_manage dp:subscription_delete dp:sub_plan_read dp:sub_plan_write dp:sub_plan_manage dp:sub_plan_delete dp:idp_read dp:idp_write dp:idp_manage dp:idp_delete dp:view_read dp:view_write dp:view_manage dp:view_delete dp:km_read dp:km_write dp:km_manage dp:km_delete dp:label_read dp:label_write dp:label_manage dp:label_delete dp:provider_read dp:provider_write dp:provider_manage dp:provider_delete dp:event_read dp:delivery_manage dp:utility_write dp:utility_manage dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dev" log "Provisioning configs/config-platform-api.toml ..." if [ -f "$PLATFORM_API_CONFIG" ]; then @@ -146,39 +157,49 @@ else # platform-api container. It's gitignored: for a static, no-dependencies # starting point instead (e.g. running platform-api directly, without # ./setup.sh), copy configs/config-platform-api-template.toml instead. - cat > "$PLATFORM_API_CONFIG" <<'EOF' + # Unquoted heredoc: $ADMIN_SCOPES is interpolated by bash below, while every + # {{ env "..." }} token is left untouched for platform-api's own config + # loader to resolve at container startup (no literal "$" appears in this + # file otherwise, so nothing else gets touched by the expansion). + cat > "$PLATFORM_API_CONFIG" </dev/null; then - log " - AUTH_FILE_BASED_USERS already set in api-platform.env, leaving admin credentials as-is" +CREDENTIALS_PROVISIONED=false +if grep -q "^APIP_CP_ADMIN_USERNAME=" "$ENV_FILE" 2>/dev/null; then + log " - APIP_CP_ADMIN_USERNAME already set in api-platform.env, leaving admin credentials as-is" else GENERATED_PASSWORD="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | cut -c1-20)" [ -n "$GENERATED_PASSWORD" ] || fail "failed to generate an admin password." + if [ -z "${ADMIN_USERNAME:-}" ] && [ -t 0 ]; then + read -r -p "Admin username [admin]: " ADMIN_USERNAME + fi + ADMIN_USERNAME="${ADMIN_USERNAME:-admin}" + + if [ -z "${ADMIN_PASSWORD:-}" ] && [ -t 0 ]; then + read -r -s -p "Admin password [press Enter to generate one]: " ADMIN_PASSWORD + echo + fi + ADMIN_PASSWORD="${ADMIN_PASSWORD:-$GENERATED_PASSWORD}" + # Use a throwaway httpd container for bcrypt hashing (htpasswd -B) rather than # requiring apache2-utils to be installed on the host — docker is already a # hard requirement for the rest of this workflow. - ADMIN_HASH="$(docker run --rm httpd:2.4-alpine htpasswd -nbBC 12 "$ADMIN_USERNAME" "$GENERATED_PASSWORD" | cut -d: -f2)" + ADMIN_HASH="$(docker run --rm httpd:2.4-alpine htpasswd -nbBC 12 "$ADMIN_USERNAME" "$ADMIN_PASSWORD" | cut -d: -f2)" [ -n "$ADMIN_HASH" ] || fail "failed to hash the admin password (is docker able to pull httpd:2.4-alpine?)." - # Docker Compose's env_file: loader applies its own ${VAR} interpolation to - # file content, so a bcrypt hash's "$2y$12$..." gets silently mangled (each - # "$xyz" segment treated as an undefined variable reference and blanked out) - # unless every literal "$" is escaped as "$$" here. This is Compose-file - # syntax, not JSON syntax — platform-api itself receives the un-escaped hash - # once Compose substitutes "$$" back to "$" before injecting the container env. - ADMIN_HASH_ESCAPED="${ADMIN_HASH//\$/\$\$}" - - # Matches platform-api's own FileBasedUser JSON shape exactly (see - # platform-api/config/config.go: FileBasedUser / fileBasedUsersDecodeHook). - # configs/config-platform-api.toml references this via - # users = '{{ env "AUTH_FILE_BASED_USERS" }}' under [auth.file_based]. - AUTH_FILE_BASED_USERS_JSON="$(printf '[{"username":"%s","password_hash":"%s","scopes":"%s"}]' \ - "$ADMIN_USERNAME" "$ADMIN_HASH_ESCAPED" "$ADMIN_SCOPES")" - printf 'AUTH_FILE_BASED_USERS=%s\n' "$AUTH_FILE_BASED_USERS_JSON" >> "$ENV_FILE" - log " - AUTH_FILE_BASED_USERS generated in api-platform.env with a random admin password" + # Written raw, un-escaped — docker-compose.yaml's env_file: entries use + # `format: raw`, which passes file content through byte-for-byte with no + # ${VAR} interpolation, so a literal bcrypt hash ("$2y$12$...") survives + # into the container as-is. Escaping "$" as "$$" here would corrupt it. + # Read by config-platform-api.toml's [[auth.file_based.users]] entry — + # scopes lives there as a plain literal, not in this env file. + set_env_var "APIP_CP_ADMIN_USERNAME" "$ADMIN_USERNAME" + set_env_var "APIP_CP_ADMIN_PASSWORD_HASH" "$ADMIN_HASH" + + CREDENTIALS_PROVISIONED=true fi echo log "Setup complete." echo -if [ -n "$GENERATED_PASSWORD" ]; then +if [ "$CREDENTIALS_PROVISIONED" = true ]; then echo " ------------------------------------------------------------------" - echo " Admin login: ${ADMIN_USERNAME} / ${GENERATED_PASSWORD}" + echo " Admin login: ${ADMIN_USERNAME} / ${ADMIN_PASSWORD}" echo " This password will not be shown again — copy it now." - echo " (It is stored, bcrypt-hashed, in api-platform.env's AUTH_FILE_BASED_USERS)" + echo " (It is stored, bcrypt-hashed, in api-platform.env's APIP_CP_ADMIN_PASSWORD_HASH)" echo " ------------------------------------------------------------------" echo fi From 860437d2d7a92b407f230b52694a838fe4a02967 Mon Sep 17 00:00:00 2001 From: Lasantha Samarakoon Date: Thu, 16 Jul 2026 11:29:01 +0530 Subject: [PATCH 5/8] Remove redundant startup log --- portals/developer-portal/src/server.js | 1 - 1 file changed, 1 deletion(-) diff --git a/portals/developer-portal/src/server.js b/portals/developer-portal/src/server.js index f793bf5b4..e91f08962 100644 --- a/portals/developer-portal/src/server.js +++ b/portals/developer-portal/src/server.js @@ -76,7 +76,6 @@ function logStartupBanner() { // shorter and avoids baking view-naming details into the banner. const visitUrl = `${config.server.baseUrl}${orgSegment}`; printBanner(visitUrl); - logger.info('Developer Portal started', { visitUrl }); if (config.demo?.enabled) { logger.warn( From 5550a4646e8ed82116190fc025b5f9501eccd0ba Mon Sep 17 00:00:00 2001 From: Lasantha Samarakoon Date: Thu, 16 Jul 2026 12:45:40 +0530 Subject: [PATCH 6/8] Remove env vars from docker compose files --- portals/developer-portal/README.md | 23 ++++------- portals/developer-portal/configs/config.toml | 43 ++++++++++++++------ portals/developer-portal/docker-compose.yaml | 11 +---- portals/developer-portal/package.json | 1 + 4 files changed, 41 insertions(+), 37 deletions(-) diff --git a/portals/developer-portal/README.md b/portals/developer-portal/README.md index 94f40cecb..f118728f4 100644 --- a/portals/developer-portal/README.md +++ b/portals/developer-portal/README.md @@ -139,18 +139,9 @@ Use this for active development, custom IdP configuration, or when you prefer to `configs/config.toml` already ships with sensible defaults — edit it directly for custom settings. `configs/config-template.toml` is the full annotated reference of every available setting; see [Configuration reference](#configuration-reference) below. -### 2. Configure HTTP mode (optional) +### 2. Use `npm run start:local`, not `npm start` -Open `configs/config.toml` and confirm these are set (they are the defaults): - -```toml -[tls] -enabled = false - -[server] -base_url = "http://localhost:3000" -port = 3000 -``` +`configs/config.toml`'s own defaults are wired for the Docker Compose topology (TLS on, pointing at a cert only the containers have, `platform_api.base_url` pointing at the `platform-api` hostname that only resolves inside the compose network). Plain `npm start` inherits those as-is and will fail — there's no `/app` filesystem or bind-mounted cert here. `npm run start:local` (`package.json`) overrides all of it in one place: TLS off, `http://localhost:3000`, and `platform_api.base_url` pointed at `localhost` (see [Local auth](#local-auth) if you're running the Platform API sidecar). ### 3. Configure the Identity Provider (optional) @@ -211,7 +202,7 @@ No manual step is required. ```bash npm install -npm start +npm run start:local ``` Open **http://localhost:3000/default/views/default** @@ -224,7 +215,7 @@ Seeds a set of sample APIs into the default organisation. Works with both the Do Get a Bearer token first, then pass it via `DEVPORTAL_TOKEN`. The examples below use `admin`/`admin` — substitute the credentials `./setup.sh` printed (or run `ADMIN_USERNAME=admin ADMIN_PASSWORD=admin ./setup.sh` for this fixed pair): -**npm start (HTTP):** +**npm run start:local (HTTP):** ```bash TOKEN=$(curl -sk -X POST "https://localhost:9243/api/portal/v0.9/auth/login" \ -d "username=admin&password=admin" | jq -r .token) @@ -257,13 +248,13 @@ password_hash = "$2y$10$..." # bcrypt hash — generate with: htpasswd -bnBC 1 scopes = "dp:org_manage dp:api_manage ..." ``` -The portal config (or `APIP_DP_PLATFORMAPI_*` env vars) must point to the Platform API. Docker Compose sets these automatically: +The portal config (or `APIP_DP_PLATFORMAPI_*` env vars) must point to the Platform API. `config.toml`'s own defaults assume Docker Compose, where `platform-api` is a resolvable hostname on the compose network — `npm run start:local` already overrides `base_url` to `https://localhost:9243` (the sidecar's port published to the host) and `insecure = true` (self-signed cert), so no manual edit is needed for that flow: ```toml [platform_api] -base_url = "https://platform-api:9243" # env: APIP_DP_PLATFORMAPI_BASEURL +base_url = "https://localhost:9243" # env: APIP_DP_PLATFORMAPI_BASEURL jwt_secret = "" # same as the Platform API's APIP_CP_AUTH_JWT_SECRET_KEY — env: APIP_DP_PLATFORMAPI_JWTSECRET -insecure = false # set true when Platform API uses a self-signed cert +insecure = true # Platform API uses a self-signed cert ``` For production, configure an OIDC identity provider per organization instead of local auth. diff --git a/portals/developer-portal/configs/config.toml b/portals/developer-portal/configs/config.toml index 9bb233ad3..879db830d 100644 --- a/portals/developer-portal/configs/config.toml +++ b/portals/developer-portal/configs/config.toml @@ -28,34 +28,53 @@ # Partial substitution works too: 'foo-{{ env "X" }}' resolves to "foo-bar" if # X=bar. See src/config/configLoader.js for the full implementation. # -# Every key below except [security] carries a fallback matching its built-in -# default (src/config/configDefaults.js), so `npm start` with no env vars set -# behaves exactly as before — docker-compose.yaml / api-platform.env only need -# to set the env var to override it, never to define config.toml's own default. +# This file's own fallbacks (the third arg to {{ env "NAME" "default" }} below) +# are intentionally wired for the Docker Compose topology (docker-compose.yaml), +# NOT for src/config/configDefaults.js's generic, dependency-free defaults — +# e.g. tls.enabled defaults to true here (a real cert is bind-mounted at +# /etc/devportal/tls) vs. false in configDefaults.js (no cert available for a +# bare `npm start`). configDefaults.js's DEFAULTS only take effect for keys this +# file doesn't list at all (idp, organization, uploads, etc.), or if this file +# itself were absent entirely (e.g. the packaged `pkg` binary run standalone) — +# see configDefaults.js's own comment for why its values stay generic. +# +# Running locally without Docker? Use `npm run start:local` instead of +# `npm start` (package.json) — it overrides tls.enabled, server.base_url, and +# platform_api.* to values that make sense outside Compose, all in one place. +# Plain `npm start` inherits this file's Compose-oriented defaults as-is and +# will fail trying to read a TLS cert path that only exists in the containers. [server] -base_url = '{{ env "APIP_DP_SERVER_BASEURL" "http://localhost:3000" }}' +base_url = '{{ env "APIP_DP_SERVER_BASEURL" "https://localhost:3000" }}' [tls] -enabled = '{{ env "APIP_DP_TLS_ENABLED" "false" }}' -cert_file = '{{ env "APIP_DP_TLS_CERTFILE" "./resources/security/client-truststore.pem" }}' -key_file = '{{ env "APIP_DP_TLS_KEYFILE" "./resources/security/private-key.pem" }}' -ca_file = '{{ env "APIP_DP_TLS_CAFILE" "./resources/security/client-truststore.pem" }}' +enabled = '{{ env "APIP_DP_TLS_ENABLED" "true" }}' +cert_file = '{{ env "APIP_DP_TLS_CERTFILE" "/etc/devportal/tls/cert.pem" }}' +key_file = '{{ env "APIP_DP_TLS_KEYFILE" "/etc/devportal/tls/key.pem" }}' +ca_file = '{{ env "APIP_DP_TLS_CAFILE" "/etc/devportal/tls/cert.pem" }}' [logging] console_only = '{{ env "APIP_DP_LOGGING_CONSOLEONLY" "true" }}' [database] -file = '{{ env "APIP_DP_DATABASE_FILE" "./devportal.db" }}' +# Relative — resolves to /app/data/devportal.db under Compose (cwd /app), +# matching the sqlite_data volume mount at /app/data; Docker creates that +# directory itself as the volume's mount point, so no extra step is needed +# there. `npm start` (cwd = repo root) resolves this to ./data/devportal.db +# instead — that directory does NOT get created automatically; see README's +# "Development (npm start)" section. +file = '{{ env "APIP_DP_DATABASE_FILE" "./data/devportal.db" }}' [demo] +# Left generic (Compose still overrides via the "${demo:-true}" shell toggle — +# see docker-compose.yaml — since that value is chosen per-invocation, not fixed). enabled = '{{ env "APIP_DP_DEMO_ENABLED" "false" }}' [platform_api] # Used for local auth credential validation when idp.client_id is empty. -base_url = '{{ env "APIP_DP_PLATFORMAPI_BASEURL" "" }}' +base_url = '{{ env "APIP_DP_PLATFORMAPI_BASEURL" "https://platform-api:9243" }}' jwt_secret = '{{ env "APIP_DP_PLATFORMAPI_JWTSECRET" "" }}' -insecure = '{{ env "APIP_DP_PLATFORMAPI_INSECURE" "false" }}' +insecure = '{{ env "APIP_DP_PLATFORMAPI_INSECURE" "true" }}' [security] # Required — devportal fails closed at startup if either doesn't resolve to a diff --git a/portals/developer-portal/docker-compose.yaml b/portals/developer-portal/docker-compose.yaml index add0715e7..fc604249a 100644 --- a/portals/developer-portal/docker-compose.yaml +++ b/portals/developer-portal/docker-compose.yaml @@ -41,16 +41,9 @@ services: platform-api: condition: service_healthy environment: - APIP_DP_DATABASE_FILE: /app/data/devportal.db - APIP_DP_TLS_ENABLED: "true" - APIP_DP_SERVER_BASEURL: "https://localhost:3000" - APIP_DP_TLS_CERTFILE: "/etc/devportal/tls/cert.pem" - APIP_DP_TLS_KEYFILE: "/etc/devportal/tls/key.pem" - APIP_DP_TLS_CAFILE: "/etc/devportal/tls/cert.pem" + # Per-invocation toggle (demo=false docker compose up) — config.toml can't + # express this, since the value is chosen at compose-up time, not fixed. APIP_DP_DEMO_ENABLED: "${demo:-true}" - APIP_DP_LOGGING_CONSOLEONLY: "true" - APIP_DP_PLATFORMAPI_BASEURL: "https://platform-api:9243" - APIP_DP_PLATFORMAPI_INSECURE: "true" env_file: - path: api-platform.env required: true diff --git a/portals/developer-portal/package.json b/portals/developer-portal/package.json index 9619781ae..e6faf70a1 100644 --- a/portals/developer-portal/package.json +++ b/portals/developer-portal/package.json @@ -6,6 +6,7 @@ "scripts": { "design-mode": "npm run build-css --watch & nodemon src/dev-server.js", "start": "npm rebuild better-sqlite3 --ignore-scripts=false && NODE_ENV=development nodemon src/server.js", + "start:local": "APIP_DP_TLS_ENABLED=false APIP_DP_SERVER_BASEURL=http://localhost:3000 APIP_DP_PLATFORMAPI_BASEURL=https://localhost:9243 APIP_DP_PLATFORMAPI_INSECURE=true npm run start", "debug": "nodemon --inspect src/server.js", "multi-tenant": "npm run build-css --watch & nodemon src/multi-tenant.js", "build-css": "node watcher.js", From 88a864dded8b398a32aba950ce7f68a13a3735b1 Mon Sep 17 00:00:00 2001 From: Lasantha Samarakoon Date: Fri, 17 Jul 2026 09:29:56 +0530 Subject: [PATCH 7/8] Remove demo mode and add seed-samples.sh script --- portals/developer-portal/.dockerignore | 10 +- portals/developer-portal/Makefile | 17 +- portals/developer-portal/README.md | 28 +- .../configs/config-platform-api-template.toml | 2 +- .../configs/config-template.toml | 9 - portals/developer-portal/configs/config.toml | 5 - .../database/schema.postgres.sql | 4 +- .../developer-portal/distribution/README.md | 23 +- .../docker-compose.platform-api.yaml | 8 +- portals/developer-portal/docker-compose.yaml | 4 - .../docs/introduction/quick-start.md | 4 +- .../developer-portal/scripts/seed-samples.sh | 174 ++++++++++ .../developer-portal/{ => scripts}/setup.sh | 23 +- portals/developer-portal/seeders/seed-apis.sh | 126 -------- .../src/config/configDefaults.js | 3 - .../src/controllers/apiContentController.js | 28 -- .../src/controllers/orgContentController.js | 4 - .../controllers/viewConfigureController.js | 3 +- portals/developer-portal/src/dao/apiDao.js | 6 +- .../src/defaultContent/pages/home/page.hbs | 1 - .../home/partials/onboarding-overlay.hbs | 116 ------- .../settings/partials/cfg-apis-panel.hbs | 5 - .../src/routes/pages/apiContentRoute.js | 5 - .../src/scripts/onboarding-overlay.js | 176 ----------- .../src/scripts/settings-apis.js | 32 -- portals/developer-portal/src/server.js | 8 - .../src/services/apiMetadataService.js | 2 + .../src/services/sampleSeederService.js | 297 ------------------ portals/developer-portal/src/utils/util.js | 1 - .../{scripts => tools}/drift_check.js | 2 +- .../{scripts => tools}/generate-ddl.js | 8 +- 31 files changed, 254 insertions(+), 880 deletions(-) create mode 100755 portals/developer-portal/scripts/seed-samples.sh rename portals/developer-portal/{ => scripts}/setup.sh (85%) delete mode 100755 portals/developer-portal/seeders/seed-apis.sh delete mode 100644 portals/developer-portal/src/defaultContent/pages/home/partials/onboarding-overlay.hbs delete mode 100644 portals/developer-portal/src/scripts/onboarding-overlay.js delete mode 100644 portals/developer-portal/src/services/sampleSeederService.js rename portals/developer-portal/{scripts => tools}/drift_check.js (99%) rename portals/developer-portal/{scripts => tools}/generate-ddl.js (97%) diff --git a/portals/developer-portal/.dockerignore b/portals/developer-portal/.dockerignore index 3381783d4..612801a2d 100644 --- a/portals/developer-portal/.dockerignore +++ b/portals/developer-portal/.dockerignore @@ -22,6 +22,14 @@ docker-compose*.yaml # ── Database init / seed scripts (volume-mounted into postgres) ─── artifacts/ +# ── Sample APIs/MCPs (deployed via ./scripts/seed-samples.sh against the +# REST API, not read from disk by the running app) ───────────────── +samples/ + +# ── Host-side operational scripts (setup.sh, seed-samples.sh) — run against +# a running container from the host, never executed inside it ────── +scripts/ + # ── Build tooling ───────────────────────────────────────────────── Makefile VERSION @@ -35,7 +43,7 @@ README.md LICENSE docs/*.md bin/ -scripts/ +tools/ # ── Git / editor metadata ───────────────────────────────────────── .git diff --git a/portals/developer-portal/Makefile b/portals/developer-portal/Makefile index a43d83f18..3c566b079 100644 --- a/portals/developer-portal/Makefile +++ b/portals/developer-portal/Makefile @@ -142,6 +142,9 @@ dist: clean-dist ## Build standalone developer portal distribution zip @mkdir -p $(DIST_DIR)/configs @mkdir -p $(DIST_DIR)/resources/developer-portal/db-scripts @cp -R database/* $(DIST_DIR)/resources/developer-portal/db-scripts + @mkdir -p $(DIST_DIR)/resources/samples + @cp -R samples/apis $(DIST_DIR)/resources/samples/ + @cp -R samples/mcps $(DIST_DIR)/resources/samples/ @cp configs/config.toml $(DIST_DIR)/configs/config.toml @cp configs/config-template.toml $(DIST_DIR)/configs/config-template.toml @cp configs/config-platform-api-template.toml $(DIST_DIR)/configs/config-platform-api.toml @@ -149,8 +152,10 @@ dist: clean-dist ## Build standalone developer portal distribution zip @cp docker-compose.yaml $(DIST_DIR)/docker-compose.yaml @cp distribution/README.md $(DIST_DIR)/README.md @mkdir -p $(DIST_DIR)/scripts - @cp setup.sh $(DIST_DIR)/scripts/setup.sh + @cp scripts/setup.sh $(DIST_DIR)/scripts/setup.sh @chmod +x $(DIST_DIR)/scripts/setup.sh + @cp scripts/seed-samples.sh $(DIST_DIR)/scripts/seed-samples.sh + @chmod +x $(DIST_DIR)/scripts/seed-samples.sh @sed -i.bak \ -e 's|image: .*/developer-portal:[^[:space:]]*|image: $(DEVPORTAL_IMAGE):$(DIST_VERSION)|g' \ $(DIST_DIR)/docker-compose.yaml @@ -174,11 +179,11 @@ clean: clean-dist ## Clean all build artifacts .PHONY: generate-ddl generate-ddl: ## Generate DDL schema files from Sequelize models for all supported dialects @echo "Generating DDL files (postgres, mysql, mariadb, mssql, sqlite)..." - @node scripts/generate-ddl.js postgres - @node scripts/generate-ddl.js mysql - @node scripts/generate-ddl.js mariadb - @node scripts/generate-ddl.js mssql - @node scripts/generate-ddl.js sqlite + @node tools/generate-ddl.js postgres + @node tools/generate-ddl.js mysql + @node tools/generate-ddl.js mariadb + @node tools/generate-ddl.js mssql + @node tools/generate-ddl.js sqlite @echo "Done. Files written to database/" # Docs Targets diff --git a/portals/developer-portal/README.md b/portals/developer-portal/README.md index f118728f4..12eb0168c 100644 --- a/portals/developer-portal/README.md +++ b/portals/developer-portal/README.md @@ -22,18 +22,18 @@ For end-user documentation, see [docs/](docs/). ## Quick Start (Docker Compose) -The fastest way to get the portal running — no local Node install required. Requires `openssl` and Docker (used by `./setup.sh` to bcrypt-hash the admin password). +The fastest way to get the portal running — no local Node install required. Requires `openssl` and Docker (used by `./scripts/setup.sh` to bcrypt-hash the admin password). ### Run ```bash -./setup.sh +./scripts/setup.sh docker compose up ``` -`./setup.sh` is a one-time step: it generates devportal's and the Platform API's encryption/JWT secrets, a self-signed TLS certificate, and an admin user into `api-platform.env` (git-ignored) and `configs/config-platform-api.toml` (also git-ignored — copy `configs/config-platform-api-template.toml` instead for a static, no-dependencies starting point). It prompts for an admin username/password interactively, or generates a random password if you press Enter; set `ADMIN_USERNAME`/`ADMIN_PASSWORD` env vars to skip the prompts (e.g. in CI). Safe to re-run — it only fills in what's missing and never overwrites an existing value; to build devportal from source instead of using the published image, run `docker compose up --build`. +`./scripts/setup.sh` is a one-time step: it generates devportal's and the Platform API's encryption/JWT secrets, a self-signed TLS certificate, and an admin user into `api-platform.env` (git-ignored) and `configs/config-platform-api.toml` (also git-ignored — copy `configs/config-platform-api-template.toml` instead for a static, no-dependencies starting point). It prompts for an admin username/password interactively, or generates a random password if you press Enter; set `ADMIN_USERNAME`/`ADMIN_PASSWORD` env vars to skip the prompts (e.g. in CI). Safe to re-run — it only fills in what's missing and never overwrites an existing value; to build devportal from source instead of using the published image, run `docker compose up --build`. -Then open **https://localhost:3000/default/views/default** and log in with the admin credentials `./setup.sh` printed. +Then open **https://localhost:3000/default/views/default** and log in with the admin credentials `./scripts/setup.sh` printed. > **Browser warning:** the TLS certificate is self-signed. Click **Advanced → Proceed** (Chrome) or **Accept the Risk** (Firefox) to continue. @@ -211,23 +211,13 @@ Open **http://localhost:3000/default/views/default** ## Seed Sample APIs (optional) -Seeds a set of sample APIs into the default organisation. Works with both the Docker Compose and `npm start` workflows. +Deploys the sample APIs and MCP servers under `samples/` into the default organisation, entirely through the public REST API — devportal itself has no built-in seeding logic. Works with both the Docker Compose and `npm start` workflows. -Get a Bearer token first, then pass it via `DEVPORTAL_TOKEN`. The examples below use `admin`/`admin` — substitute the credentials `./setup.sh` printed (or run `ADMIN_USERNAME=admin ADMIN_PASSWORD=admin ./setup.sh` for this fixed pair): - -**npm run start:local (HTTP):** ```bash -TOKEN=$(curl -sk -X POST "https://localhost:9243/api/portal/v0.9/auth/login" \ - -d "username=admin&password=admin" | jq -r .token) -DEVPORTAL_URL=http://localhost:3000 DEVPORTAL_TOKEN=$TOKEN ./seeders/seed-apis.sh +./scripts/seed-samples.sh ``` -**Docker Compose (HTTPS):** -```bash -TOKEN=$(curl -sk -X POST "https://localhost:9243/api/portal/v0.9/auth/login" \ - -d "username=admin&password=admin" | jq -r .token) -DEVPORTAL_URL=https://localhost:3000 DEVPORTAL_TOKEN=$TOKEN ./seeders/seed-apis.sh -``` +Prompts for the admin username/password (or set `ADMIN_USERNAME`/`ADMIN_PASSWORD` to skip the prompt, e.g. in CI). Safe to re-run — entries that already exist are skipped. Set `DEVPORTAL_URL`/`PLATFORM_API_URL` to override the defaults (`https://localhost:3000` / `https://localhost:9243`) — e.g. `DEVPORTAL_URL=http://localhost:3000` when running against `npm run start:local`. --- @@ -412,7 +402,7 @@ paths: ``` ```bash -# Get a Bearer token (substitute the credentials ./setup.sh printed) +# Get a Bearer token (substitute the credentials ./scripts/setup.sh printed) TOKEN=$(curl -sk -X POST "https://localhost:9243/api/portal/v0.9/auth/login" \ -d "username=admin&password=admin" | jq -r .token) @@ -436,5 +426,5 @@ Refresh the portal — the Ping API now appears in the catalog. Click it to view | Organization | `default` | | Default view | `default` | | Portal URL | `https://localhost:3000/default/views/default` | -| Admin credentials | printed by `./setup.sh` (local auth) | +| Admin credentials | printed by `./scripts/setup.sh` (local auth) | | Sample API | `Ping API` visible in the catalog | diff --git a/portals/developer-portal/configs/config-platform-api-template.toml b/portals/developer-portal/configs/config-platform-api-template.toml index 3233326eb..9cf0bb1cd 100644 --- a/portals/developer-portal/configs/config-platform-api-template.toml +++ b/portals/developer-portal/configs/config-platform-api-template.toml @@ -97,7 +97,7 @@ region = "us" [[auth.file_based.users]] username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' -scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:webbroker_api:manage ap:websub_api:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:git:read ap:api_key:read dp:org_read dp:org_write dp:org_manage dp:org_delete dp:org_content_read dp:org_content_write dp:org_content_manage dp:org_content_delete dp:api_read dp:api_write dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:mcp_read dp:mcp_create dp:mcp_update dp:mcp_manage dp:mcp_delete dp:mcp_content_read dp:mcp_content_create dp:mcp_content_update dp:mcp_content_manage dp:mcp_content_delete dp:mcp_key_read dp:mcp_key_create dp:mcp_key_update dp:mcp_key_manage dp:mcp_key_revoke dp:api_key_read dp:api_key_write dp:api_key_manage dp:api_key_revoke dp:api_flow_read dp:api_flow_write dp:api_flow_manage dp:api_flow_delete dp:api_workflow_read dp:api_workflow_create dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_read dp:app_write dp:app_manage dp:app_delete dp:app_key_write dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_read dp:subscription_write dp:subscription_manage dp:subscription_delete dp:sub_plan_read dp:sub_plan_write dp:sub_plan_manage dp:sub_plan_delete dp:idp_read dp:idp_write dp:idp_manage dp:idp_delete dp:view_read dp:view_write dp:view_manage dp:view_delete dp:km_read dp:km_write dp:km_manage dp:km_delete dp:label_read dp:label_write dp:label_manage dp:label_delete dp:provider_read dp:provider_write dp:provider_manage dp:provider_delete dp:event_read dp:delivery_manage dp:utility_write dp:utility_manage dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dev" +scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:webbroker_api:manage ap:websub_api:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:git:read ap:api_key:read dp:org_read dp:org_write dp:org_manage dp:org_delete dp:org_content_read dp:org_content_write dp:org_content_manage dp:org_content_delete dp:api_read dp:api_write dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:mcp_create dp:mcp_read dp:mcp_update dp:mcp_delete dp:mcp_manage dp:mcp_content_create dp:mcp_content_read dp:mcp_content_update dp:mcp_content_delete dp:mcp_content_manage dp:mcp_key_create dp:mcp_key_read dp:mcp_key_update dp:mcp_key_revoke dp:mcp_key_manage dp:api_key_read dp:api_key_write dp:api_key_manage dp:api_key_revoke dp:api_flow_read dp:api_flow_write dp:api_flow_manage dp:api_flow_delete dp:api_workflow_read dp:api_workflow_create dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_read dp:app_write dp:app_manage dp:app_delete dp:app_key_write dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_read dp:subscription_write dp:subscription_manage dp:subscription_delete dp:sub_plan_read dp:sub_plan_write dp:sub_plan_manage dp:sub_plan_delete dp:idp_read dp:idp_write dp:idp_manage dp:idp_delete dp:view_read dp:view_write dp:view_manage dp:view_delete dp:km_read dp:km_write dp:km_manage dp:km_delete dp:label_read dp:label_write dp:label_manage dp:label_delete dp:provider_read dp:provider_write dp:provider_manage dp:provider_delete dp:event_read dp:delivery_manage dp:utility_write dp:utility_manage dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dev" # --------------------------------------------------------------------------- # DevPortal integration — disabled (devportal calls us, not the other way) diff --git a/portals/developer-portal/configs/config-template.toml b/portals/developer-portal/configs/config-template.toml index 8a2b5d9cb..79c39329e 100644 --- a/portals/developer-portal/configs/config-template.toml +++ b/portals/developer-portal/configs/config-template.toml @@ -161,15 +161,6 @@ insecure = false default_name = "default" # "" disables auto-seeding auto_create_subscription_plans = true # Auto-create Bronze/Silver/Gold/Unlimited/AsyncUnlimited -# ============================================================================= -# DEMO MODE CONFIGURATION -# ============================================================================= -# Enables the "Seed sample APIs" action and onboarding overlay. Do not enable -# in production deployments. - -[demo] -enabled = false - # ============================================================================= # DESIGN MODE CONFIGURATION # ============================================================================= diff --git a/portals/developer-portal/configs/config.toml b/portals/developer-portal/configs/config.toml index 879db830d..4cbc6ef13 100644 --- a/portals/developer-portal/configs/config.toml +++ b/portals/developer-portal/configs/config.toml @@ -65,11 +65,6 @@ console_only = '{{ env "APIP_DP_LOGGING_CONSOLEONLY" "true" }}' # "Development (npm start)" section. file = '{{ env "APIP_DP_DATABASE_FILE" "./data/devportal.db" }}' -[demo] -# Left generic (Compose still overrides via the "${demo:-true}" shell toggle — -# see docker-compose.yaml — since that value is chosen per-invocation, not fixed). -enabled = '{{ env "APIP_DP_DEMO_ENABLED" "false" }}' - [platform_api] # Used for local auth credential validation when idp.client_id is empty. base_url = '{{ env "APIP_DP_PLATFORMAPI_BASEURL" "https://platform-api:9243" }}' diff --git a/portals/developer-portal/database/schema.postgres.sql b/portals/developer-portal/database/schema.postgres.sql index d5630bb7b..2d3fdd93c 100644 --- a/portals/developer-portal/database/schema.postgres.sql +++ b/portals/developer-portal/database/schema.postgres.sql @@ -1,6 +1,6 @@ --- Generated by scripts/generate-ddl.js — do not edit manually. +-- Generated by tools/generate-ddl.js — do not edit manually. -- Dialect: postgres --- Re-generate with: make generate-ddl (or: node scripts/generate-ddl.js postgres) +-- Re-generate with: make generate-ddl (or: node tools/generate-ddl.js postgres) -- PostgreSQL ENUM types (enum_*) are declared below before the tables that reference them. DROP TABLE IF EXISTS "dp_webhook_subscribers" CASCADE; diff --git a/portals/developer-portal/distribution/README.md b/portals/developer-portal/distribution/README.md index de398947d..f16aa6949 100644 --- a/portals/developer-portal/distribution/README.md +++ b/portals/developer-portal/distribution/README.md @@ -9,15 +9,19 @@ wso2apip-developer-portal-/ ├── README.md ├── docker-compose.yaml # Developer Portal + Platform API ├── scripts/ -│ └── setup.sh # One-time TLS + secrets provisioning +│ ├── setup.sh # One-time TLS + secrets provisioning +│ └── seed-samples.sh # Optional: deploy the bundled sample APIs/MCPs ├── configs/ │ ├── config.toml # Developer Portal active configuration │ ├── config-template.toml # Developer Portal full configuration reference │ ├── config-platform-api.toml # Platform API active configuration │ └── config-platform-api-template.toml # Platform API full configuration reference └── resources/ - └── developer-portal/ - └── db-scripts/ # Developer Portal PostgreSQL schema (reference copy) + ├── developer-portal/ + │ └── db-scripts/ # Developer Portal PostgreSQL schema (reference copy) + └── samples/ + ├── apis/ # Sample REST/GraphQL/SOAP APIs + └── mcps/ # Sample MCP servers ``` ## Prerequisites @@ -54,6 +58,16 @@ Open the Developer Portal in a browser at `https://localhost:3000/default/views/ > **Browser trust warning?** Both services use a self-signed TLS certificate by default. Click **Advanced → Proceed** to continue. See [Custom TLS Certificates](#custom-tls-certificates) to remove the warning permanently. +## Seed Sample APIs (optional) + +Deploys the sample APIs and MCP servers under `resources/samples/` into the default organisation, entirely through the public REST API: + +```bash +./scripts/seed-samples.sh +``` + +Prompts for the admin username/password (or set `ADMIN_USERNAME`/`ADMIN_PASSWORD` to skip the prompt). Safe to re-run — entries that already exist are skipped. + ## Exposed Ports | Port | Service | Description | @@ -79,7 +93,6 @@ Environment overrides go in `api-platform.env` (git-ignored; loaded into both co | `[idp].client_id` | Set to delegate login to an external OIDC provider — leave empty for local auth via `[platform_api]` | _(empty)_ | | `[platform_api].base_url` | Address of the Platform API local-auth sidecar | `https://platform-api:9243` | | `[organization].default_name` | Organization bootstrapped automatically on first start | `default` | -| `[demo].enabled` | Shows the "Seed sample APIs" action and onboarding overlay | `${demo:-true}` | ### Platform API (`configs/config-platform-api.toml`) @@ -91,7 +104,7 @@ Environment overrides go in `api-platform.env` (git-ignored; loaded into both co | `[auth.jwt].secret_key` | 32-byte HMAC key signing login JWTs | _(from `setup.sh`)_ | | `[auth.idp]` | JWKS-based IDP auth — disabled in quickstart mode | disabled | | `[[auth.file_based.users]]` | Local user credentials — `username`/`password_hash` resolved from `setup.sh`'s env vars, `scopes` is a plain literal | admin, generated by `setup.sh` | -| `[tls].cert_dir` | Listener certificate directory | `/etc/platform-api/tls` | +| `[https].cert_dir` | Listener certificate directory | `/etc/platform-api/tls` | See `configs/config-template.toml` and `configs/config-platform-api-template.toml` for a fully-commented reference of every available setting. diff --git a/portals/developer-portal/docker-compose.platform-api.yaml b/portals/developer-portal/docker-compose.platform-api.yaml index 6f1680d46..9fd6bcb7c 100644 --- a/portals/developer-portal/docker-compose.platform-api.yaml +++ b/portals/developer-portal/docker-compose.platform-api.yaml @@ -19,7 +19,7 @@ # Platform API only — for local development with npm start # # Quick start: -# 1. Run ./setup.sh once — generates the required secrets into api-platform.env +# 1. Run ./scripts/setup.sh once — generates the required secrets into api-platform.env # (also read directly by `npm start` below) and configs/config-platform-api.toml. # 2. Build the Platform API image from source (../../platform-api): # cd ../../platform-api && make build @@ -28,11 +28,11 @@ # 3. docker compose -f docker-compose.platform-api.yaml up -d # 4. npm start # 5. Open http://localhost:3000 -# Login with the admin credentials ./setup.sh printed. +# Login with the admin credentials ./scripts/setup.sh printed. # # There is no ephemeral/demo fallback — the Platform API fails closed at startup # if APIP_CP_ENCRYPTION_KEY or APIP_CP_AUTH_JWT_SECRET_KEY is missing (both are -# generated by ./setup.sh into api-platform.env and loaded here via env_file:). +# generated by ./scripts/setup.sh into api-platform.env and loaded here via env_file:). services: # One-shot init container: generates the TLS pair the platform-api HTTPS @@ -58,7 +58,7 @@ services: env_file: - path: api-platform.env required: false # APIP_CP_ENCRYPTION_KEY / APIP_CP_AUTH_JWT_SECRET_KEY / - # AUTH_FILE_BASED_USERS live here — run ./setup.sh to generate it. + # AUTH_FILE_BASED_USERS live here — run ./scripts/setup.sh to generate it. volumes: - ./configs/config-platform-api.toml:/etc/platform-api/config-platform-api.toml:ro - platform-api-data:/app/data diff --git a/portals/developer-portal/docker-compose.yaml b/portals/developer-portal/docker-compose.yaml index fc604249a..daa5bcbcd 100644 --- a/portals/developer-portal/docker-compose.yaml +++ b/portals/developer-portal/docker-compose.yaml @@ -40,10 +40,6 @@ services: depends_on: platform-api: condition: service_healthy - environment: - # Per-invocation toggle (demo=false docker compose up) — config.toml can't - # express this, since the value is chosen at compose-up time, not fixed. - APIP_DP_DEMO_ENABLED: "${demo:-true}" env_file: - path: api-platform.env required: true diff --git a/portals/developer-portal/docs/introduction/quick-start.md b/portals/developer-portal/docs/introduction/quick-start.md index 8ba1ffb55..122b15ede 100644 --- a/portals/developer-portal/docs/introduction/quick-start.md +++ b/portals/developer-portal/docs/introduction/quick-start.md @@ -34,7 +34,7 @@ cp configs/config-platform-api-template.toml configs/config-platform-api.toml docker compose up ``` -This starts the Developer Portal in demo mode (SQLite by default). On first boot the database schema and a default organization (`default`) with a `default` view are created automatically. +This starts the Developer Portal (SQLite by default). On first boot the database schema and a default organization (`default`) with a `default` view are created automatically. ### 4. Open the portal @@ -46,7 +46,7 @@ https://localhost:3000/default/views/default Sign in with `admin` / `admin` (the credentials defined in `configs/config-platform-api.toml`). -You should see the default API catalog page. Since demo mode is on, you'll get a one-time onboarding prompt offering to deploy a set of sample APIs/MCPs — this is optional and can be skipped. You can also trigger it later from **Settings → Manage APIs → Seed sample APIs**. +You should see the default API catalog page, empty until you publish an API (next step) or run `./scripts/seed-samples.sh` to deploy a set of ready-made sample APIs/MCPs. ### 5. Publish your first API diff --git a/portals/developer-portal/scripts/seed-samples.sh b/portals/developer-portal/scripts/seed-samples.sh new file mode 100755 index 000000000..307e1891e --- /dev/null +++ b/portals/developer-portal/scripts/seed-samples.sh @@ -0,0 +1,174 @@ +#!/bin/bash + +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# -------------------------------------------------------------------- + +# Deploys the bundled sample APIs and MCP servers into a running Developer +# Portal, entirely through its public REST API — no in-process seeding logic +# ships in the application itself. +# +# Prerequisites: ./scripts/setup.sh has been run and `docker compose up` is running. +# +# Usage (from the project root, or the standalone distribution zip — same +# layout in both): +# ./scripts/seed-samples.sh +# +# ADMIN_USERNAME / ADMIN_PASSWORD environment variables skip the interactive +# credential prompt (used by CI). DEVPORTAL_URL / PLATFORM_API_URL override +# the default local URLs. +# +# Safe to re-run: entries that already exist (matched by name + version) are +# skipped, not duplicated. + +set -euo pipefail + +THIS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Same layout-detection approach as setup.sh — this script is copied verbatim +# into the distribution zip's scripts/ directory (see Makefile's dist target), +# and both layouts put it one level below docker-compose.yaml. +if [ -f "$THIS_DIR/../docker-compose.yaml" ]; then + ROOT_DIR="$(cd "$THIS_DIR/.." && pwd)" +elif [ -f "$THIS_DIR/docker-compose.yaml" ]; then + ROOT_DIR="$THIS_DIR" +else + echo "[seed-samples] ERROR: could not find docker-compose.yaml next to this script or its parent directory." >&2 + echo "[seed-samples] Run this as ./scripts/seed-samples.sh from the project root or the distribution zip." >&2 + exit 1 +fi + +# The distribution zip ships samples under resources/samples/; the source +# repo keeps them at samples/ (see Makefile's dist target for the copy). +if [ -d "$ROOT_DIR/resources/samples" ]; then + SAMPLES_DIR="$ROOT_DIR/resources/samples" +elif [ -d "$ROOT_DIR/samples" ]; then + SAMPLES_DIR="$ROOT_DIR/samples" +else + echo "[seed-samples] ERROR: no samples directory found (looked for resources/samples and samples next to $ROOT_DIR)." >&2 + exit 1 +fi + +DEVPORTAL_URL="${DEVPORTAL_URL:-https://localhost:3000}" +PLATFORM_API_URL="${PLATFORM_API_URL:-https://localhost:9243}" + +log() { echo "[seed-samples] $*"; } +fail() { echo "[seed-samples] ERROR: $*" >&2; exit 1; } + +command -v curl >/dev/null 2>&1 || fail "curl is required but not found on PATH." +command -v jq >/dev/null 2>&1 || fail "jq is required but not found on PATH." +command -v zip >/dev/null 2>&1 || fail "zip is required but not found on PATH." + +if [ -z "${ADMIN_USERNAME:-}" ] && [ -t 0 ]; then + read -r -p "Devportal admin username: " ADMIN_USERNAME +fi +[ -n "${ADMIN_USERNAME:-}" ] || fail "an admin username is required (set ADMIN_USERNAME or run interactively)." + +if [ -z "${ADMIN_PASSWORD:-}" ] && [ -t 0 ]; then + read -r -s -p "Devportal admin password: " ADMIN_PASSWORD + echo +fi +[ -n "${ADMIN_PASSWORD:-}" ] || fail "an admin password is required (set ADMIN_PASSWORD or run interactively)." + +log "Logging in to Platform API at $PLATFORM_API_URL ..." +TOKEN=$(curl -sk -X POST "$PLATFORM_API_URL/api/portal/v0.9/auth/login" \ + -d "username=$ADMIN_USERNAME&password=$ADMIN_PASSWORD" | jq -r '.token // empty') +[ -n "$TOKEN" ] || fail "failed to obtain a token — check the credentials and that Platform API is reachable at $PLATFORM_API_URL." +AUTH_HEADER="Authorization: Bearer $TOKEN" + +# Uploads sample_dir/docs/ as the content ZIP for an already-created API/MCP server. +seed_docs() { + local sample_dir="$1" resource_path="$2" + [ -d "$sample_dir/docs" ] || return 0 + + local tmp_zip + tmp_zip="$(mktemp /tmp/devportal-docs-XXXXXX)" + rm -f "$tmp_zip" + tmp_zip="${tmp_zip}.zip" + # Wrapped in a top-level folder (named after the sample) rather than zipping + # docs/ bare at the root — the server unwraps a single top-level directory + # entry looking for web/ or docs/ inside it; zipping docs/ alone as that one + # entry gets unwrapped too, so it then looks for (and fails to find) docs/docs. + (cd "$sample_dir/.." && zip -qr "$tmp_zip" "$(basename "$sample_dir")/docs/") + + local http_code + http_code=$(curl -sk -o /dev/null -w "%{http_code}" -X POST \ + "$DEVPORTAL_URL$resource_path/assets" \ + -H "$AUTH_HEADER" \ + -F "content=@$tmp_zip;type=application/zip") + rm -f "$tmp_zip" + + if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then + log " docs OK ($http_code)" + else + log " docs FAILED ($http_code)" + fi +} + +# Creates one API or MCP server entry from a sample directory (api.yaml + optional +# definition.* + optional docs/), via the given collection endpoint. +seed_entry() { + local sample_dir="$1" endpoint="$2" + local name; name="$(basename "$sample_dir")" + local api_yaml="$sample_dir/api.yaml" + if [ ! -f "$api_yaml" ]; then + log "skipping $name: no api.yaml" + return + fi + + local definition + definition=$(compgen -G "$sample_dir/definition.*" 2>/dev/null | head -1 || true) + + log "Seeding: $name" + local curl_args=(-sk -X POST "$DEVPORTAL_URL/api/v0.9/$endpoint" \ + -H "$AUTH_HEADER" \ + -F "metadata=@$api_yaml;type=application/yaml") + if [ -n "$definition" ]; then + curl_args+=(-F "definition=@$definition;type=application/octet-stream") + fi + + local response http_code body id + response=$(curl "${curl_args[@]}" -w "\n%{http_code}") + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | sed '$d') + + if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then + id=$(echo "$body" | jq -r '.id // empty') + log " OK ($http_code) — id: $id" + [ -n "$id" ] && seed_docs "$sample_dir" "/api/v0.9/$endpoint/$id" + elif [ "$http_code" -eq 409 ]; then + log " already exists, skipping" + else + log " FAILED ($http_code): $body" + fi +} + +if [ -d "$SAMPLES_DIR/apis" ]; then + log "Seeding sample APIs from $SAMPLES_DIR/apis ..." + for dir in "$SAMPLES_DIR"/apis/*/; do + [ -d "$dir" ] && seed_entry "${dir%/}" "apis" + done +fi + +if [ -d "$SAMPLES_DIR/mcps" ]; then + log "Seeding sample MCP servers from $SAMPLES_DIR/mcps ..." + for dir in "$SAMPLES_DIR"/mcps/*/; do + [ -d "$dir" ] && seed_entry "${dir%/}" "mcp-servers" + done +fi + +log "Done." diff --git a/portals/developer-portal/setup.sh b/portals/developer-portal/scripts/setup.sh similarity index 85% rename from portals/developer-portal/setup.sh rename to portals/developer-portal/scripts/setup.sh index 8f5d6ee99..9a3d27a96 100755 --- a/portals/developer-portal/setup.sh +++ b/portals/developer-portal/scripts/setup.sh @@ -35,9 +35,9 @@ # silently generating or accepting a weaker one. Re-running this script is # safe: it only fills in what's missing and never overwrites an existing value. # -# Usage: -# ./setup.sh (from the project root — local dev) -# ./scripts/setup.sh (from the standalone distribution zip) +# Usage (from the project root, or the standalone distribution zip — same +# layout in both): +# ./scripts/setup.sh # docker compose up # # ADMIN_USERNAME / ADMIN_PASSWORD environment variables skip the interactive @@ -53,16 +53,17 @@ set -euo pipefail THIS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # This same script is copied verbatim into the distribution zip's scripts/ -# directory (see Makefile's dist target), so it can't assume its own directory -# is the project root — detect which layout is in play by locating -# docker-compose.yaml, which is always a direct sibling of the real root. -if [ -f "$THIS_DIR/docker-compose.yaml" ]; then - ROOT_DIR="$THIS_DIR" -elif [ -f "$THIS_DIR/../docker-compose.yaml" ]; then +# directory (see Makefile's dist target) — both layouts put this script one +# level below docker-compose.yaml, so ROOT_DIR is always the parent directory. +# The direct-sibling check is kept as a fallback in case this file is ever +# copied elsewhere. +if [ -f "$THIS_DIR/../docker-compose.yaml" ]; then ROOT_DIR="$(cd "$THIS_DIR/.." && pwd)" +elif [ -f "$THIS_DIR/docker-compose.yaml" ]; then + ROOT_DIR="$THIS_DIR" else echo "[setup] ERROR: could not find docker-compose.yaml next to this script or its parent directory." >&2 - echo "[setup] Run this as ./setup.sh from the project root, or ./scripts/setup.sh from the distribution zip." >&2 + echo "[setup] Run this as ./scripts/setup.sh from the project root or the distribution zip." >&2 exit 1 fi cd "$ROOT_DIR" @@ -145,7 +146,7 @@ set_env_var "APIP_DP_PLATFORMAPI_JWTSECRET" "$JWT_SECRET_KEY" # Full-access scopes for the seeded admin user — ap:* (platform-admin) plus every # dp:*_manage scope so it can manage every Developer Portal resource area. A plain # literal in config-platform-api.toml (never templated), since it carries no secret. -ADMIN_SCOPES="ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:webbroker_api:manage ap:websub_api:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:git:read ap:api_key:read dp:org_read dp:org_write dp:org_manage dp:org_delete dp:org_content_read dp:org_content_write dp:org_content_manage dp:org_content_delete dp:api_read dp:api_write dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:api_key_read dp:api_key_write dp:api_key_manage dp:api_key_revoke dp:api_flow_read dp:api_flow_write dp:api_flow_manage dp:api_flow_delete dp:api_workflow_read dp:api_workflow_create dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_read dp:app_write dp:app_manage dp:app_delete dp:app_key_write dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_read dp:subscription_write dp:subscription_manage dp:subscription_delete dp:sub_plan_read dp:sub_plan_write dp:sub_plan_manage dp:sub_plan_delete dp:idp_read dp:idp_write dp:idp_manage dp:idp_delete dp:view_read dp:view_write dp:view_manage dp:view_delete dp:km_read dp:km_write dp:km_manage dp:km_delete dp:label_read dp:label_write dp:label_manage dp:label_delete dp:provider_read dp:provider_write dp:provider_manage dp:provider_delete dp:event_read dp:delivery_manage dp:utility_write dp:utility_manage dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dev" +ADMIN_SCOPES="ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:webbroker_api:manage ap:websub_api:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:git:read ap:api_key:read dp:org_read dp:org_write dp:org_manage dp:org_delete dp:org_content_read dp:org_content_write dp:org_content_manage dp:org_content_delete dp:api_read dp:api_write dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:mcp_create dp:mcp_read dp:mcp_update dp:mcp_delete dp:mcp_manage dp:mcp_content_create dp:mcp_content_read dp:mcp_content_update dp:mcp_content_delete dp:mcp_content_manage dp:mcp_key_create dp:mcp_key_read dp:mcp_key_update dp:mcp_key_revoke dp:mcp_key_manage dp:api_key_read dp:api_key_write dp:api_key_manage dp:api_key_revoke dp:api_flow_read dp:api_flow_write dp:api_flow_manage dp:api_flow_delete dp:api_workflow_read dp:api_workflow_create dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_read dp:app_write dp:app_manage dp:app_delete dp:app_key_write dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_read dp:subscription_write dp:subscription_manage dp:subscription_delete dp:sub_plan_read dp:sub_plan_write dp:sub_plan_manage dp:sub_plan_delete dp:idp_read dp:idp_write dp:idp_manage dp:idp_delete dp:view_read dp:view_write dp:view_manage dp:view_delete dp:km_read dp:km_write dp:km_manage dp:km_delete dp:label_read dp:label_write dp:label_manage dp:label_delete dp:provider_read dp:provider_write dp:provider_manage dp:provider_delete dp:event_read dp:delivery_manage dp:utility_write dp:utility_manage dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dev" log "Provisioning configs/config-platform-api.toml ..." if [ -f "$PLATFORM_API_CONFIG" ]; then diff --git a/portals/developer-portal/seeders/seed-apis.sh b/portals/developer-portal/seeders/seed-apis.sh deleted file mode 100755 index 37f35e739..000000000 --- a/portals/developer-portal/seeders/seed-apis.sh +++ /dev/null @@ -1,126 +0,0 @@ -#!/bin/bash -set -euo pipefail - -BASE_URL="${DEVPORTAL_URL:-https://localhost:3000}" -PLATFORM_API_URL="${PLATFORM_API_URL:-https://localhost:9243}" -CREDENTIALS="${DEVPORTAL_CREDENTIALS:-admin:admin}" -ORG_HANDLE="${ORG_HANDLE:-default}" - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -APIS_DIR="$SCRIPT_DIR/../samples/apis" - -for cmd in jq zip; do - if ! command -v "$cmd" &>/dev/null; then - echo "Error: $cmd is required but not installed" - exit 1 - fi -done - -# Acquire bearer token from Platform API -PLATFORM_USER="${CREDENTIALS%%:*}" -PLATFORM_PASS="${CREDENTIALS#*:}" - -TOKEN=$(curl -sk -X POST "$PLATFORM_API_URL/api/portal/v0.9/auth/login" \ - -d "username=$PLATFORM_USER&password=$PLATFORM_PASS" | jq -r '.token // empty') -if [ -z "$TOKEN" ]; then - echo "Error: failed to obtain token from $PLATFORM_API_URL — check credentials and that Platform API is running" - exit 1 -fi - -AUTH_HEADER="Authorization: Bearer $TOKEN" - -# Resolve ORG_ID — use env var if set, otherwise discover by handle -if [ -z "${ORG_ID:-}" ]; then - ORG_ID=$(curl -sk -H "$AUTH_HEADER" "$BASE_URL/organizations" | \ - jq -r --arg h "$ORG_HANDLE" '.[] | select(.orgHandle == $h) | .orgId // empty') - if [ -z "$ORG_ID" ]; then - echo "Error: organization with handle '$ORG_HANDLE' not found. Ensure the server has started with APIP_DP_ORGANIZATION_DEFAULTNAME set." - exit 1 - fi - echo "Resolved ORG_ID: $ORG_ID" -fi - -seed_docs() { - local api_dir="$1" - local api_id="$2" - local docs_dir="$api_dir/docs" - - local tmp_zip - tmp_zip=$(mktemp /tmp/api-docs-XXXXXX) - rm -f "$tmp_zip" - tmp_zip="${tmp_zip}.zip" - - (cd "$(dirname "${api_dir%/}")" && zip -qr "$tmp_zip" "$(basename "${api_dir%/}")/docs/") - - local response http_code body - response=$(curl -sk -X POST \ - "$BASE_URL/o/$ORG_ID/devportal/v1/apis/$api_id/content" \ - -H "$AUTH_HEADER" \ - -F "apiContent=@$tmp_zip;type=application/zip" \ - -w "\n%{http_code}") - http_code=$(echo "$response" | tail -1) - body=$(echo "$response" | sed '$d') - - rm -f "$tmp_zip" - - if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then - echo " docs OK ($http_code)" - else - echo " docs FAILED ($http_code): $body" - fi -} - -seed_api() { - local api_dir="$1" - local api_name - api_name=$(basename "$api_dir") - - local api_yaml="$api_dir/api.yaml" - - if [ ! -f "$api_yaml" ]; then - echo " skipping $api_name: no api.yaml found" - return - fi - - echo "Seeding: $api_name" - - # Find definition.* file (definition.yaml, definition.yml, definition.graphql, etc.) - local definition - definition=$(compgen -G "$api_dir/definition.*" 2>/dev/null | head -1) - - local curl_args=(-sk -X POST \ - "$BASE_URL/o/$ORG_ID/devportal/v1/apis" \ - -H "$AUTH_HEADER" \ - -F "api=@$api_yaml;type=application/yaml") - - if [ -n "$definition" ]; then - local ext="${definition##*.}" - if [ "$ext" = "graphql" ]; then - curl_args+=(-F "schemaDefinition=@$definition;type=application/octet-stream") - else - curl_args+=(-F "apiDefinition=@$definition;type=application/octet-stream") - fi - fi - - local response http_code body api_id - response=$(curl "${curl_args[@]}" -w "\n%{http_code}") - http_code=$(echo "$response" | tail -1) - body=$(echo "$response" | sed '$d') - - if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then - api_id=$(echo "$body" | jq -r '.apiID') - echo " OK ($http_code) — apiID: $api_id" - - if [ -d "$api_dir/docs" ] && [ "$api_id" != "null" ]; then - seed_docs "$api_dir" "$api_id" - fi - elif [ "$http_code" -eq 409 ]; then - echo " already exists, skipping" - else - echo " FAILED ($http_code): $body" - fi -} - -for api_dir in "$APIS_DIR"/*/; do - [ -d "$api_dir" ] && seed_api "$api_dir" -done diff --git a/portals/developer-portal/src/config/configDefaults.js b/portals/developer-portal/src/config/configDefaults.js index 0ad1fd350..9b5116b29 100644 --- a/portals/developer-portal/src/config/configDefaults.js +++ b/portals/developer-portal/src/config/configDefaults.js @@ -131,9 +131,6 @@ const DEFAULTS = { // because src/utils/util.js and viewConfigureController.js read it defensively. apiWorkflows: true, }, - demo: { - enabled: false, - }, designMode: { enabled: false, pathToLayout: './src/defaultContent/', diff --git a/portals/developer-portal/src/controllers/apiContentController.js b/portals/developer-portal/src/controllers/apiContentController.js index 05654882c..60cf116f9 100644 --- a/portals/developer-portal/src/controllers/apiContentController.js +++ b/portals/developer-portal/src/controllers/apiContentController.js @@ -35,7 +35,6 @@ const apiMetadataService = require('../services/apiMetadataService'); const { apiUsesApiKeySecurity, findSubscriptionTokenHeader } = require('../utils/apiDefinitionUtil'); const sampleApiLoader = require('../utils/sampleApiLoader'); const adminService = require('../services/adminService'); -const { seedSampleAPIs, seedSampleMCPs, markSamplesSeeded } = require('../services/sampleSeederService'); const apiWorkflowService = require('../services/apiWorkflowService'); const { buildSchema, getIntrospectionQuery, graphql: executeGraphQL } = require('graphql'); const yaml = require('js-yaml'); @@ -129,7 +128,6 @@ const loadAPIs = async (req, res, next) => { profile: req.isAuthenticated() ? profile : null, devportalMode: devportalMode, isReadOnlyMode: config.server.readOnlyMode, - demoEnabled: config.demo?.enabled === true, applications: [] }; @@ -1501,31 +1499,6 @@ const loadDocumentMd = async (req, res) => { } }; -const seedSamples = async (req, res) => { - const { orgName } = req.params; - // Demo mode is the gate, deliberately in place of an admin check — it's meant to let - // anyone (including anonymous visitors) populate a public demo instance with samples. - // Outside demo mode this is unreachable regardless of login/role. - if (!config.demo?.enabled) { - return util.sendError(res, 403, 'Demo mode disabled'); - } - try { - const orgDetails = await orgDao.get(orgName); - const apiResults = await seedSampleAPIs(orgDetails.uuid); - const mcpResults = await seedSampleMCPs(orgDetails.uuid); - const results = [...apiResults, ...mcpResults]; - const deployed = results.filter(r => r.status === 'ok').length; - const skipped = results.filter(r => r.status === 'exists').length; - const failed = results.filter(r => r.status === 'failed').length; - markSamplesSeeded(); - logger.info('Sample seed complete', { orgName, deployed, skipped, failed }); - res.json({ results, deployed, skipped, failed }); - } catch (err) { - logger.error('Sample seed error', { orgName, error: err.message }); - util.sendError(res, 500, 'Failed to seed samples'); - } -}; - module.exports = { loadAPIs, loadAPIContent, @@ -1538,5 +1511,4 @@ module.exports = { loadAPIContentMd, loadDocumentMd, loadSpecificationRaw: loadAPIDefinitionRaw, - seedSamples, }; diff --git a/portals/developer-portal/src/controllers/orgContentController.js b/portals/developer-portal/src/controllers/orgContentController.js index 264ebba08..d27d02df5 100644 --- a/portals/developer-portal/src/controllers/orgContentController.js +++ b/portals/developer-portal/src/controllers/orgContentController.js @@ -25,7 +25,6 @@ const { renderTemplate, renderTemplateFromAPI } = require('../utils/util'); const { config } = require('../config/configLoader'); const constants = require('../utils/constants'); const orgDao = require('../dao/organizationDao'); -const { areSamplesSeeded } = require('../services/sampleSeederService'); const loadOrganizationContent = async (req, res, next) => { @@ -81,9 +80,6 @@ const loadOrgContentFromAPI = async (req, res, next) => { devportalMode: devportalMode, baseUrl: '/' + orgName + constants.ROUTE.VIEWS_PATH + req.params.viewName, profile: req.isAuthenticated() ? profile : null, - // In demo mode, show to everyone (including anonymous visitors) — it's a public - // demo sandbox, not an admin-only workflow. Outside demo mode this is always false. - showOnboarding: config.demo?.enabled === true && !areSamplesSeeded(), }; html = await renderTemplateFromAPI(templateContent, orgId, orgName, 'pages/home', req.params.viewName); } catch (error) { diff --git a/portals/developer-portal/src/controllers/viewConfigureController.js b/portals/developer-portal/src/controllers/viewConfigureController.js index 35b87a31f..d6d6f8aee 100644 --- a/portals/developer-portal/src/controllers/viewConfigureController.js +++ b/portals/developer-portal/src/controllers/viewConfigureController.js @@ -54,8 +54,7 @@ const loadSettingsPage = async (req, res) => { let templateContent = { settingsUrl, csrfToken, - showApiWorkflowsNav: config.features?.apiWorkflows === true, - demoMode: config.demo?.enabled === true + showApiWorkflowsNav: config.features?.apiWorkflows === true }; try { templateContent.loggedOrg = orgName; diff --git a/portals/developer-portal/src/dao/apiDao.js b/portals/developer-portal/src/dao/apiDao.js index acbad98da..5edb5ca8f 100644 --- a/portals/developer-portal/src/dao/apiDao.js +++ b/portals/developer-portal/src/dao/apiDao.js @@ -281,14 +281,16 @@ const list = async (orgId, viewName, t, typeFilter) => { return apiList; }; -const listFromAllViews = async (orgId, t) => { +const listFromAllViews = async (orgId, t, typeFilter) => { let apiList = []; try { const publicAPIS = await APIMetadata.findAll({ where: { org_uuid: orgId, - status: { [Op.in]: [constants.API_STATUS.PUBLISHED, constants.API_STATUS.DEPRECATED] } + status: { [Op.in]: [constants.API_STATUS.PUBLISHED, constants.API_STATUS.DEPRECATED] }, + ...(typeFilter?.include && { type: typeFilter.include }), + ...(typeFilter?.exclude && { type: { [Op.ne]: typeFilter.exclude } }) }, include: [{ model: APIContent, diff --git a/portals/developer-portal/src/defaultContent/pages/home/page.hbs b/portals/developer-portal/src/defaultContent/pages/home/page.hbs index 9db2bc560..060ee37e6 100644 --- a/portals/developer-portal/src/defaultContent/pages/home/page.hbs +++ b/portals/developer-portal/src/defaultContent/pages/home/page.hbs @@ -25,4 +25,3 @@ {{/pageScripts}} {{> home showApiWorkflowsNav=showApiWorkflowsNav}} -{{#if showOnboarding}}{{> onboarding-overlay baseUrl=baseUrl}}{{/if}} diff --git a/portals/developer-portal/src/defaultContent/pages/home/partials/onboarding-overlay.hbs b/portals/developer-portal/src/defaultContent/pages/home/partials/onboarding-overlay.hbs deleted file mode 100644 index 76e660466..000000000 --- a/portals/developer-portal/src/defaultContent/pages/home/partials/onboarding-overlay.hbs +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - diff --git a/portals/developer-portal/src/pages/settings/partials/cfg-apis-panel.hbs b/portals/developer-portal/src/pages/settings/partials/cfg-apis-panel.hbs index 636b26d53..11ce6a8ae 100644 --- a/portals/developer-portal/src/pages/settings/partials/cfg-apis-panel.hbs +++ b/portals/developer-portal/src/pages/settings/partials/cfg-apis-panel.hbs @@ -9,11 +9,6 @@

Add, edit, and remove the APIs visible in this developer portal.

- {{#if demoMode}} - - {{/if}} diff --git a/portals/developer-portal/src/routes/pages/apiContentRoute.js b/portals/developer-portal/src/routes/pages/apiContentRoute.js index 7e54f9b4a..09226a071 100644 --- a/portals/developer-portal/src/routes/pages/apiContentRoute.js +++ b/portals/developer-portal/src/routes/pages/apiContentRoute.js @@ -45,11 +45,6 @@ router.get('/:orgName/views/:viewName/mcps.md', (req, res, next) => { next(); }, util.enforcePortalMode, apiController.loadMCPsMd); -router.post('/:orgName/views/:viewName/apis/seed-samples', (req, res, next) => { - if (req.params.orgName === 'favicon.ico') return res.status(404).json({ error: 'Not Found' }); - next(); -}, ensureAuthenticated, apiController.seedSamples); - router.get('/:orgName/views/:viewName/apis', (req, res, next) => { if (req.params.orgName === 'favicon.ico') { return res.status(404).send('Not Found'); diff --git a/portals/developer-portal/src/scripts/onboarding-overlay.js b/portals/developer-portal/src/scripts/onboarding-overlay.js deleted file mode 100644 index 7c4262fa0..000000000 --- a/portals/developer-portal/src/scripts/onboarding-overlay.js +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -/* eslint-disable no-undef */ - -(function () { - var STORAGE_KEY = 'dp_ob_dismissed'; - - var overlay = document.getElementById('dp-onboarding'); - if (!overlay) return; - var BASE_URL = overlay.dataset.baseUrl || ''; - var SEED_URL = BASE_URL + '/apis/seed-samples'; - var introPanel = document.getElementById('dp-ob-intro'); - var deployPanel = document.getElementById('dp-ob-deploying'); - var donePanel = document.getElementById('dp-ob-done'); - var deployBtn = document.getElementById('dp-ob-deploy-btn'); - var skipBtn = document.getElementById('dp-ob-skip-btn'); - var progressFill = document.getElementById('dp-ob-progress-fill'); - var progressLabel = document.getElementById('dp-ob-progress-label'); - var curCard = document.getElementById('dp-ob-cur-card'); - var curSpinner = document.getElementById('dp-ob-cur-spinner'); - var curCheck = document.getElementById('dp-ob-cur-check'); - var curNameEl = document.getElementById('dp-ob-cur-name'); - var curMetaEl = document.getElementById('dp-ob-cur-meta'); - var doneMsgEl = document.getElementById('dp-ob-done-msg'); - var doneDetailEl = document.getElementById('dp-ob-done-detail'); - - // Sample APIs on the filesystem — drives the progress animation - var SAMPLES = [ - { name: 'Ping API', meta: 'REST · v1.0' }, - { name: 'Booking API', meta: 'REST · v1.0' }, - { name: 'Catalog API', meta: 'REST · v1.0' }, - { name: 'Swagger Petstore', meta: 'REST · v1.0.7' }, - { name: 'Countries GraphQL API', meta: 'GraphQL · v1.0'}, - { name: 'Navigation API', meta: 'WS · v1.0' }, - { name: 'TravelAssistantMCP', meta: 'MCP · v1.0.0' }, - ]; - var TOTAL = SAMPLES.length; - - function dismiss() { - try { sessionStorage.setItem(STORAGE_KEY, '1'); } catch (_) {} - overlay.style.display = 'none'; - } - - function show(panelEl) { - [introPanel, deployPanel, donePanel].forEach(function (p) { p.classList.remove('active'); }); - panelEl.classList.add('active'); - } - - function runAnimation(onComplete) { - var done = 0; - - function loadCard(s, checked) { - curNameEl.textContent = s.name; - curMetaEl.textContent = s.meta; - curSpinner.style.display = checked ? 'none' : 'block'; - curCheck.style.display = checked ? 'inline-flex' : 'none'; - } - - function fadeIn() { - curCard.style.transition = 'none'; - curCard.style.opacity = '0'; - curCard.style.transform = 'translateY(6px)'; - curCard.offsetHeight; // flush layout so transition:none takes effect - curCard.style.transition = ''; - curCard.style.opacity = '1'; - curCard.style.transform = 'none'; - } - - function fadeOut(cb) { - curCard.style.opacity = '0'; - curCard.style.transform = 'translateY(-6px)'; - setTimeout(cb, 280); - } - - function tick(i) { - if (i >= TOTAL) { onComplete && onComplete(); return; } - var s = SAMPLES[i]; - loadCard(s, false); - fadeIn(); - setTimeout(function () { - done++; - loadCard(s, true); - progressFill.style.width = Math.round(done / TOTAL * 100) + '%'; - progressLabel.textContent = done + ' of ' + TOTAL + ' deployed. This only takes a moment.'; - setTimeout(function () { - if (i + 1 >= TOTAL) { onComplete && onComplete(); return; } - fadeOut(function () { tick(i + 1); }); - }, 480); - }, 760); - } - - tick(0); - } - - function runDeploy() { - show(deployPanel); - deployBtn.disabled = true; - - var animDone = false; - var fetchDone = false; - var fetchResult = null; - - function tryFinish() { - if (!animDone || !fetchDone) return; - var deployed = fetchResult ? fetchResult.deployed : 0; - var skipped = fetchResult ? fetchResult.skipped : 0; - var failed = fetchResult ? fetchResult.failed : 0; - var detail = []; - if (deployed > 0) detail.push(deployed + ' deployed'); - if (skipped > 0) detail.push(skipped + ' already existed'); - if (failed > 0) detail.push(failed + ' failed'); - doneMsgEl.textContent = 'Sample APIs are ready in your portal. Taking you to your API catalog…'; - doneDetailEl.textContent = detail.join(' · '); - show(donePanel); - setTimeout(function () { - try { sessionStorage.setItem(STORAGE_KEY, '1'); } catch (_) {} - window.location.href = BASE_URL + '/apis'; - }, 2000); - } - - // Kick off animation - runAnimation(function () { animDone = true; tryFinish(); }); - - // Kick off real deployment - var csrfToken = ''; - try { - var m = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/); - if (m) csrfToken = decodeURIComponent(m[1]); - } catch (_) {} - - fetch(SEED_URL, { - method: 'POST', - credentials: 'same-origin', - headers: { 'X-CSRF-Token': csrfToken, 'Content-Type': 'application/json' }, - }) - .then(function (r) { return r.json(); }) - .then(function (data) { fetchResult = data; }) - .catch(function (err) { - console.warn('Sample seed fetch error:', err); - fetchResult = { deployed: 0, skipped: 0, failed: 0 }; - }) - .finally(function () { fetchDone = true; tryFinish(); }); - } - - // Server already decides whether to render this overlay at all (demo mode + not yet - // seeded — see showOnboarding in orgContentController.js). This session-scoped check - // just avoids re-showing it after a "skip" click within the same browser session. - try { - if (sessionStorage.getItem(STORAGE_KEY)) { - overlay.style.display = 'none'; - return; - } - } catch (_) {} - - deployBtn.addEventListener('click', runDeploy); - skipBtn.addEventListener('click', dismiss); - overlay.addEventListener('click', function (e) { if (e.target === overlay) dismiss(); }); - document.addEventListener('keydown', function (e) { - if (e.key === 'Escape' && overlay.style.display !== 'none') dismiss(); - }); -}()); diff --git a/portals/developer-portal/src/scripts/settings-apis.js b/portals/developer-portal/src/scripts/settings-apis.js index bbe65f5d6..d59b26280 100644 --- a/portals/developer-portal/src/scripts/settings-apis.js +++ b/portals/developer-portal/src/scripts/settings-apis.js @@ -693,38 +693,6 @@ document.getElementById('cfg-add-api-btn').addEventListener('click', function() { showWizard(null); }); document.getElementById('cfg-add-mcp-btn').addEventListener('click', function() { showWizard(null, 'mcp'); }); - /* ── Seed sample APIs (demo mode only) ── */ - (function() { - var btn = document.getElementById('cfg-seed-samples-btn'); - if (!btn) return; - var originalHtml = btn.innerHTML; - btn.addEventListener('click', async function() { - btn.disabled = true; - btn.innerHTML = ' Importing…'; - try { - var res = await fetch(BASE_URL + '/apis/seed-samples', { - method: 'POST', - headers: { 'X-CSRF-Token': window.devportalApi.csrfToken() }, - }); - var data = await res.json().catch(function() { return {}; }); - if (res.ok) { - var detail = []; - if (data.deployed) detail.push(data.deployed + ' deployed'); - if (data.skipped) detail.push(data.skipped + ' already existed'); - if (data.failed) detail.push(data.failed + ' failed'); - await showAlert('Sample APIs imported' + (detail.length ? ' — ' + detail.join(', ') + '.' : '. Nothing new to import.'), 'success'); - window.location.reload(); - } else { - await showAlert('Failed: ' + (data.error || res.statusText), 'error'); - } - } catch (e) { - await showAlert('Error: ' + e.message, 'error'); - } finally { - btn.disabled = false; - btn.innerHTML = originalHtml; - } - }); - }()); document.getElementById('cfg-wizard-back-link').addEventListener('click', function(e) { e.preventDefault(); hideWizard(); }); document.getElementById('cfg-wizard-cancel').addEventListener('click', function() { hideWizard(); }); document.getElementById('cfg-wizard-back-step').addEventListener('click', function() { if(currentStep>0) updateWizardStep(currentStep-1); }); diff --git a/portals/developer-portal/src/server.js b/portals/developer-portal/src/server.js index e91f08962..119d408bd 100644 --- a/portals/developer-portal/src/server.js +++ b/portals/developer-portal/src/server.js @@ -76,14 +76,6 @@ function logStartupBanner() { // shorter and avoids baking view-naming details into the banner. const visitUrl = `${config.server.baseUrl}${orgSegment}`; printBanner(visitUrl); - - if (config.demo?.enabled) { - logger.warn( - 'DEMO MODE is ENABLED (APIP_DP_DEMO_ENABLED=true) — sample APIs/MCPs can be seeded ' + - 'via Settings > Manage APIs or the onboarding overlay. Do not enable this in ' + - 'production deployments.' - ); - } } async function onListening() { diff --git a/portals/developer-portal/src/services/apiMetadataService.js b/portals/developer-portal/src/services/apiMetadataService.js index f7cae3bc3..7715f5935 100644 --- a/portals/developer-portal/src/services/apiMetadataService.js +++ b/portals/developer-portal/src/services/apiMetadataService.js @@ -441,6 +441,8 @@ const getMetadataListFromDB = async (orgId, searchTerm, tags, apiName, apiVersio retrievedAPIs = await apiDao.search(orgId, searchTerm, viewName, t, typeFilter); } else if (viewName) { retrievedAPIs = await apiDao.list(orgId, viewName, t, typeFilter); + } else { + retrievedAPIs = await apiDao.listFromAllViews(orgId, t, typeFilter); } // Create response object let apiCreationResponse = []; diff --git a/portals/developer-portal/src/services/sampleSeederService.js b/portals/developer-portal/src/services/sampleSeederService.js deleted file mode 100644 index 8e6863b41..000000000 --- a/portals/developer-portal/src/services/sampleSeederService.js +++ /dev/null @@ -1,297 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const sequelize = require('../db/sequelizeConfig'); -const apiDao = require('../dao/apiDao'); -const apiFileDao = require('../dao/apiFileDao'); -const labelDao = require('../dao/labelDao'); -const subscriptionPlanDao = require('../dao/subscriptionPlanDao'); -const constants = require('../utils/constants'); -const logger = require('../config/logger'); -const { config } = require('../config/configLoader'); -const { parseApiMetadataFromYamlFile, prepareApiDefinitionForStorage } = require('./apiMetadataService'); - -const DEFINITION_CANDIDATES = ['definition.yaml', 'definition.yml', 'definition.json', 'definition.graphql', 'definition.wsdl']; -const SAMPLES_DIR = path.join(process.cwd(), 'samples', 'apis'); -const MCP_SAMPLES_DIR = path.join(process.cwd(), 'samples', 'mcps'); -const SCHEMA_DEFINITION_FILE = constants.FILE_NAME.SCHEMA_DEFINITION_YAML_FILE_NAME; - -function findDefinitionPath(apiDir) { - for (const name of DEFINITION_CANDIDATES) { - const candidate = path.join(apiDir, name); - if (fs.existsSync(candidate)) return candidate; - } - return null; -} - -/** - * Recursively read docs/ under an API directory. - * Top-level .md files → TYPE = DOC_Other - * Files in a subdirectory (e.g. docs/FAQ/) → TYPE = DOC_FAQ - * Returns [{ type, fileName, content }] - */ -function readDocFiles(docsDir, subDir) { - const docs = []; - if (!fs.existsSync(docsDir)) return docs; - for (const entry of fs.readdirSync(docsDir)) { - if (entry.startsWith('.')) continue; - const full = path.join(docsDir, entry); - if (fs.statSync(full).isDirectory()) { - docs.push(...readDocFiles(full, entry)); - } else if (/\.(md|MD)$/.test(entry)) { - const docType = subDir || constants.DOC_TYPES.DOCS.OTHER; - docs.push({ - type: constants.DOC_TYPES.DOC_ID + docType, - fileName: entry, - content: Buffer.from(fs.readFileSync(full)), - }); - } - } - return docs; -} - -/** - * Deploy all sample APIs from samples/apis/ into the given org. - * Already-existing APIs (UniqueConstraintError) are skipped silently. - * Returns an array of { name, status ('ok'|'exists'|'failed'), apiId?, error? }. - */ -async function seedSampleAPIs(orgId) { - if (!fs.existsSync(SAMPLES_DIR)) { - logger.warn('samples/apis directory not found — skipping sample seeding', { SAMPLES_DIR }); - return []; - } - - const entries = fs.readdirSync(SAMPLES_DIR) - .filter(e => { - const p = path.join(SAMPLES_DIR, e); - return fs.statSync(p).isDirectory() && fs.existsSync(path.join(p, 'api.yaml')); - }); - - const results = []; - - for (const entry of entries) { - const apiDir = path.join(SAMPLES_DIR, entry); - let apiName = entry; - let apiId; - - try { - const yamlBuffer = Buffer.from(fs.readFileSync(path.join(apiDir, 'api.yaml'))); - const apiMetadata = parseApiMetadataFromYamlFile('api.yaml', yamlBuffer); - apiName = apiMetadata.name || entry; - - if (await apiDao.existsByNameVersion(orgId, apiName, apiMetadata.version)) { - results.push({ name: apiName, status: 'exists' }); - continue; - } - - // Load definition file if present - let apiDefinitionFile = null; - let apiFileName = ''; - const defPath = findDefinitionPath(apiDir); - if (defPath) { - const defName = path.basename(defPath); - const defBuffer = Buffer.from(fs.readFileSync(defPath)); - try { - const prepared = prepareApiDefinitionForStorage(defName, defBuffer); - apiDefinitionFile = prepared.apiDefinitionFile; - apiFileName = prepared.apiDefinitionFileName; - } catch (prepErr) { - // Non-standard type (e.g. WSDL): store raw - logger.debug(`prepareApiDefinitionForStorage skipped for ${entry}: ${prepErr.message}`); - apiDefinitionFile = defBuffer; - apiFileName = defName; - } - } - - await sequelize.transaction(async (t) => { - const created = await apiDao.create(orgId, apiMetadata, constants.SYSTEM_ACTOR, t); - apiId = created.dataValues.uuid; - - // Subscription plan mappings (skip unknown plans — don't fail the whole deployment) - if (Array.isArray(apiMetadata.subscriptionPlans) && apiMetadata.subscriptionPlans.length) { - const mappings = []; - for (const p of apiMetadata.subscriptionPlans) { - const plan = await subscriptionPlanDao.getByName(orgId, p.id); - if (plan) mappings.push({ apiId: apiId, planId: plan.uuid }); - } - if (mappings.length) await subscriptionPlanDao.createApiMapping(mappings, apiId, constants.SYSTEM_ACTOR, t); - } - - // Label mappings - const labels = Array.isArray(apiMetadata.labels) && apiMetadata.labels.length - ? apiMetadata.labels - : ['default']; - await labelDao.createApiMapping(orgId, apiId, labels, constants.SYSTEM_ACTOR, t); - - // Definition file - if (apiDefinitionFile) { - const isGraphQL = apiMetadata.type === constants.API_TYPE.GRAPHQL; - const storedName = isGraphQL ? constants.FILE_NAME.API_DEFINITION_GRAPHQL : apiFileName; - await apiFileDao.store(apiDefinitionFile, storedName, apiId, constants.DOC_TYPES.API_DEFINITION, constants.SYSTEM_ACTOR, t); - } - - // Documentation files from docs/ - const docs = readDocFiles(path.join(apiDir, 'docs'), ''); - if (docs.length) { - await apiFileDao.storeMany(docs, apiId, constants.SYSTEM_ACTOR, t); - } - }); - - results.push({ name: apiName, handle: apiMetadata.handle, status: 'ok', apiId }); - logger.info('Seeded sample API', { orgId, apiName, apiId }); - - } catch (err) { - results.push({ name: apiName, status: 'failed', error: err.message }); - logger.error('Failed to seed sample API', { orgId, entry, error: err.message }); - } - } - - return results; -} - -/** - * Deploy all sample MCP servers from samples/mcps/ into the given org. - * Each subdirectory must contain api.yaml and optionally definition.yaml and docs/. - * Returns an array of { name, status ('ok'|'exists'|'failed'), apiId?, error? }. - */ -async function seedSampleMCPs(orgId) { - if (!fs.existsSync(MCP_SAMPLES_DIR)) { - logger.warn('samples/mcps directory not found — skipping MCP seeding', { MCP_SAMPLES_DIR }); - return []; - } - - const entries = fs.readdirSync(MCP_SAMPLES_DIR) - .filter(e => { - const p = path.join(MCP_SAMPLES_DIR, e); - return fs.statSync(p).isDirectory() && fs.existsSync(path.join(p, 'api.yaml')); - }); - - const results = []; - - for (const entry of entries) { - const mcpDir = path.join(MCP_SAMPLES_DIR, entry); - let apiName = entry; - let apiId; - - try { - const yamlBuffer = Buffer.from(fs.readFileSync(path.join(mcpDir, 'api.yaml'))); - const apiMetadata = parseApiMetadataFromYamlFile('api.yaml', yamlBuffer); - apiName = apiMetadata.name || entry; - - if (await apiDao.existsByNameVersion(orgId, apiName, apiMetadata.version)) { - results.push({ name: apiName, status: 'exists' }); - continue; - } - - const schemaPath = path.join(mcpDir, SCHEMA_DEFINITION_FILE); - const schemaBuffer = fs.existsSync(schemaPath) - ? Buffer.from(fs.readFileSync(schemaPath)) - : null; - - await sequelize.transaction(async (t) => { - const created = await apiDao.create(orgId, apiMetadata, constants.SYSTEM_ACTOR, t); - apiId = created.dataValues.uuid; - - // Subscription plan mappings (skip unknown plans — don't fail the whole deployment) - if (Array.isArray(apiMetadata.subscriptionPlans) && apiMetadata.subscriptionPlans.length) { - const mappings = []; - for (const p of apiMetadata.subscriptionPlans) { - const plan = await subscriptionPlanDao.getByName(orgId, p.id); - if (plan) mappings.push({ apiId: apiId, planId: plan.uuid }); - } - if (mappings.length) await subscriptionPlanDao.createApiMapping(mappings, apiId, constants.SYSTEM_ACTOR, t); - } - - // Label mappings - const labels = Array.isArray(apiMetadata.labels) && apiMetadata.labels.length - ? apiMetadata.labels - : ['default']; - await labelDao.createApiMapping(orgId, apiId, labels, constants.SYSTEM_ACTOR, t); - - // Schema definition (tools/resources/prompts) - if (schemaBuffer) { - await apiFileDao.store( - schemaBuffer, - constants.FILE_NAME.SCHEMA_DEFINITION_YAML_FILE_NAME, - apiId, - constants.DOC_TYPES.SCHEMA_DEFINITION, - constants.SYSTEM_ACTOR, - t - ); - } - - // Documentation files from docs/ - const docs = readDocFiles(path.join(mcpDir, 'docs'), ''); - if (docs.length) { - await apiFileDao.storeMany(docs, apiId, constants.SYSTEM_ACTOR, t); - } - }); - - results.push({ name: apiName, handle: apiMetadata.handle, status: 'ok', apiId }); - logger.info('Seeded sample MCP', { orgId, apiName, apiId }); - - } catch (err) { - results.push({ name: apiName, status: 'failed', error: err.message }); - logger.error('Failed to seed sample MCP', { orgId, entry, error: err.message }); - } - } - - return results; -} - -/** - * Path to the "samples seeded" marker file. Lives alongside the SQLite DB file in the - * persisted data volume — deliberately not a DB row, so it survives even if the DB is - * swapped out, and not localStorage/sessionStorage, so it's shared across browsers/admins. - */ -function markerPath() { - const dbStorage = config.database?.file || './devportal.db'; - return path.join(path.dirname(dbStorage), '.samples-seeded'); -} - -/** - * Whether sample APIs/MCPs have already been seeded at least once for this instance. - * Best-effort: any filesystem error is treated as "not seeded" rather than throwing. - */ -function areSamplesSeeded() { - try { - return fs.existsSync(markerPath()); - } catch (err) { - logger.warn('Failed to check samples-seeded marker', { error: err.message }); - return false; - } -} - -/** - * Record that samples have been seeded. Best-effort — a failure to write the marker must - * not fail the seed operation itself (the seed already succeeded by this point). - */ -function markSamplesSeeded() { - try { - fs.mkdirSync(path.dirname(markerPath()), { recursive: true }); - fs.writeFileSync(markerPath(), new Date().toISOString()); - } catch (err) { - logger.warn('Failed to write samples-seeded marker', { error: err.message }); - } -} - -module.exports = { seedSampleAPIs, seedSampleMCPs, areSamplesSeeded, markSamplesSeeded }; diff --git a/portals/developer-portal/src/utils/util.js b/portals/developer-portal/src/utils/util.js index 1d841e8c5..fd295907b 100644 --- a/portals/developer-portal/src/utils/util.js +++ b/portals/developer-portal/src/utils/util.js @@ -947,7 +947,6 @@ function validateScripts(strContent) { "", "", "", - "", "", '', '', diff --git a/portals/developer-portal/scripts/drift_check.js b/portals/developer-portal/tools/drift_check.js similarity index 99% rename from portals/developer-portal/scripts/drift_check.js rename to portals/developer-portal/tools/drift_check.js index cd8447f0a..f49153747 100644 --- a/portals/developer-portal/scripts/drift_check.js +++ b/portals/developer-portal/tools/drift_check.js @@ -23,7 +23,7 @@ * against the spec response schemas using AJV — the same engine * express-openapi-validator uses internally. * - * node scripts/drift_check.js + * node tools/drift_check.js * * Exit code 0 means every sample matches the spec. Exit code 1 means at least * one sample failed validation; the failing operationId, status, and AJV diff --git a/portals/developer-portal/scripts/generate-ddl.js b/portals/developer-portal/tools/generate-ddl.js similarity index 97% rename from portals/developer-portal/scripts/generate-ddl.js rename to portals/developer-portal/tools/generate-ddl.js index 0a699d3a1..f2f60f242 100644 --- a/portals/developer-portal/scripts/generate-ddl.js +++ b/portals/developer-portal/tools/generate-ddl.js @@ -18,7 +18,7 @@ /* * Generates dialect-specific DDL from Sequelize model definitions. * - * node scripts/generate-ddl.js + * node tools/generate-ddl.js * * Supported dialects: postgres mysql mariadb mssql sqlite * @@ -45,7 +45,7 @@ const DRIVERS = { postgres: 'pg', mysql: 'mysql2', mariadb: 'mariadb', mssql: const dialect = process.argv[2]; if (!dialect || !SUPPORTED.includes(dialect)) { - console.error('Usage: node scripts/generate-ddl.js '); + console.error('Usage: node tools/generate-ddl.js '); console.error(' dialect: ' + SUPPORTED.join(' | ')); process.exit(1); } @@ -133,9 +133,9 @@ const qg = seq.getQueryInterface().queryGenerator; // 5. Build DDL output. // ------------------------------------------------------------------ const lines = [ - '-- Generated by scripts/generate-ddl.js — do not edit manually.', + '-- Generated by tools/generate-ddl.js — do not edit manually.', `-- Dialect: ${dialect}`, - `-- Re-generate with: make generate-ddl (or: node scripts/generate-ddl.js ${dialect})`, + `-- Re-generate with: make generate-ddl (or: node tools/generate-ddl.js ${dialect})`, ]; if (dialect === 'postgres') { lines.push('-- PostgreSQL ENUM types (enum_*) are declared below before the tables that reference them.'); From 5ab61398ef1f937d9b805ff95785b703f1b2b635 Mon Sep 17 00:00:00 2001 From: Lasantha Samarakoon Date: Fri, 17 Jul 2026 12:03:00 +0530 Subject: [PATCH 8/8] Fix local npm run --- .../docker-compose.platform-api.yaml | 43 ++++--------------- portals/developer-portal/package.json | 2 +- 2 files changed, 9 insertions(+), 36 deletions(-) diff --git a/portals/developer-portal/docker-compose.platform-api.yaml b/portals/developer-portal/docker-compose.platform-api.yaml index 9fd6bcb7c..2ddf5bb58 100644 --- a/portals/developer-portal/docker-compose.platform-api.yaml +++ b/portals/developer-portal/docker-compose.platform-api.yaml @@ -21,60 +21,33 @@ # Quick start: # 1. Run ./scripts/setup.sh once — generates the required secrets into api-platform.env # (also read directly by `npm start` below) and configs/config-platform-api.toml. -# 2. Build the Platform API image from source (../../platform-api): -# cd ../../platform-api && make build -# This is required — the published 0.10.0 image on ghcr.io predates the -# /auth/login endpoint that the username/password login form here uses. -# 3. docker compose -f docker-compose.platform-api.yaml up -d -# 4. npm start -# 5. Open http://localhost:3000 +# 2. docker compose -f docker-compose.platform-api.yaml up -d +# 3. npm start +# 4. Open http://localhost:3000 # Login with the admin credentials ./scripts/setup.sh printed. -# -# There is no ephemeral/demo fallback — the Platform API fails closed at startup -# if APIP_CP_ENCRYPTION_KEY or APIP_CP_AUTH_JWT_SECRET_KEY is missing (both are -# generated by ./scripts/setup.sh into api-platform.env and loaded here via env_file:). services: - # One-shot init container: generates the TLS pair the platform-api HTTPS - # listener requires (the server no longer generates a self-signed fallback). - platform-api-certgen: - image: alpine/openssl - 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" - volumes: - - platform-api-certs:/certs - platform-api: - image: ghcr.io/wso2/api-platform/platform-api:0.11.0 + image: ghcr.io/wso2/api-platform/platform-api:0.12.0 container_name: platform-api restart: unless-stopped command: ["-config", "/etc/platform-api/config-platform-api.toml"] env_file: - path: api-platform.env - required: false # APIP_CP_ENCRYPTION_KEY / APIP_CP_AUTH_JWT_SECRET_KEY / - # AUTH_FILE_BASED_USERS live here — run ./scripts/setup.sh to generate it. + required: true + format: raw volumes: - ./configs/config-platform-api.toml:/etc/platform-api/config-platform-api.toml:ro - platform-api-data:/app/data - - platform-api-certs:/app/data/certs + - ./resources/certificates:/etc/platform-api/tls:ro ports: - "9243:9243" - depends_on: - platform-api-certgen: - condition: service_completed_successfully healthcheck: test: ["CMD", "curl", "-fk", "https://localhost:9243/health"] interval: 30s timeout: 5s start_period: 20s retries: 3 - volumes: platform-api-data: - platform-api-certs: + driver: local diff --git a/portals/developer-portal/package.json b/portals/developer-portal/package.json index e6faf70a1..701bd9234 100644 --- a/portals/developer-portal/package.json +++ b/portals/developer-portal/package.json @@ -6,7 +6,7 @@ "scripts": { "design-mode": "npm run build-css --watch & nodemon src/dev-server.js", "start": "npm rebuild better-sqlite3 --ignore-scripts=false && NODE_ENV=development nodemon src/server.js", - "start:local": "APIP_DP_TLS_ENABLED=false APIP_DP_SERVER_BASEURL=http://localhost:3000 APIP_DP_PLATFORMAPI_BASEURL=https://localhost:9243 APIP_DP_PLATFORMAPI_INSECURE=true npm run start", + "start:local": "APIP_DP_TLS_ENABLED=false APIP_DP_SERVER_BASEURL=http://localhost:3000 APIP_DP_PLATFORMAPI_BASEURL=https://localhost:9243 APIP_DP_PLATFORMAPI_INSECURE=true APIP_DP_DATABASE_FILE=./devportal.db npm run start", "debug": "nodemon --inspect src/server.js", "multi-tenant": "npm run build-css --watch & nodemon src/multi-tenant.js", "build-css": "node watcher.js",