@lukoweb/apitogo 0.1.91 → 0.1.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/cli.js CHANGED
@@ -301,23 +301,15 @@ function firstApiInput(config2) {
301
301
  }
302
302
  return void 0;
303
303
  }
304
- function mergeAuthenticationGateway(config2) {
304
+ function normalizeGatewayPlacement(config2) {
305
305
  const authentication = asRecord2(config2.authentication);
306
306
  if (!authentication) {
307
- return void 0;
307
+ return;
308
308
  }
309
- const authGateway = asRecord2(authentication.gateway);
310
- const rootGateway = asRecord2(config2.gateway);
311
- if (!authGateway && !rootGateway) {
312
- return authentication;
309
+ if ("gateway" in authentication) {
310
+ delete authentication.gateway;
311
+ config2.authentication = authentication;
313
312
  }
314
- return {
315
- ...authentication,
316
- gateway: {
317
- ...rootGateway,
318
- ...authGateway
319
- }
320
- };
321
313
  }
322
314
  function ensureModules(config2) {
323
315
  const modules = asRecord2(config2.modules) ?? {};
@@ -378,11 +370,10 @@ function normalizeApitogoConfig(config2) {
378
370
  };
379
371
  }
380
372
  normalized.site = site;
381
- const authentication = mergeAuthenticationGateway(normalized);
373
+ const authentication = asRecord2(normalized.authentication);
382
374
  if (authentication) {
383
- normalized.authentication = authentication;
375
+ normalizeGatewayPlacement(normalized);
384
376
  }
385
- delete normalized.gateway;
386
377
  const modules = ensureModules(normalized);
387
378
  const legacyDocs = asRecord2(normalized.docs);
388
379
  const moduleDocs = asRecord2(modules.docs) ?? {};
@@ -533,13 +524,16 @@ function deriveManifestFields(config2) {
533
524
  continue;
534
525
  }
535
526
  if (key === "authentication") {
536
- const authentication2 = asRecord2(normalized.authentication);
537
- if (authentication2) {
538
- manifest.authentication = stripAuthenticationSecrets2(authentication2);
527
+ const authentication = asRecord2(normalized.authentication);
528
+ if (authentication) {
529
+ manifest.authentication = stripAuthenticationSecrets2(authentication);
539
530
  }
540
531
  continue;
541
532
  }
542
533
  if (key === "gateway") {
534
+ if (normalized.gateway !== void 0) {
535
+ manifest.gateway = normalized.gateway;
536
+ }
543
537
  continue;
544
538
  }
545
539
  if (key === "pricing") {
@@ -570,10 +564,6 @@ function deriveManifestFields(config2) {
570
564
  manifest[key] = normalized[key];
571
565
  }
572
566
  }
573
- const authentication = asRecord2(normalized.authentication);
574
- if (authentication?.gateway) {
575
- manifest.gateway = authentication.gateway;
576
- }
577
567
  return manifest;
578
568
  }
579
569
  function extractManifest(config2) {
@@ -1158,7 +1148,11 @@ var init_local_manifest = __esm({
1158
1148
  oidcAllowedIssuers: z4.array(z4.string()).optional(),
1159
1149
  oidcAudiences: z4.array(z4.string()).optional(),
1160
1150
  limitsJson: z4.string().optional(),
1161
- publicHealthActionKey: z4.string().optional()
1151
+ publicHealthActionKey: z4.string().optional(),
1152
+ publicRoutes: z4.array(z4.string()).optional(),
1153
+ cors: z4.object({
1154
+ allowedOrigins: z4.array(z4.string()).optional()
1155
+ }).optional()
1162
1156
  }).optional(),
1163
1157
  projectId: z4.string().optional(),
1164
1158
  landing: z4.object({
@@ -4878,6 +4872,13 @@ import {
4878
4872
  runnerImport,
4879
4873
  loadEnv as viteLoadEnv
4880
4874
  } from "vite";
4875
+ function resolvePublishGatewayBaseUrl() {
4876
+ const raw = process.env.APITOGO_GATEWAY_URL?.trim();
4877
+ if (!raw) {
4878
+ return void 0;
4879
+ }
4880
+ return raw.replace(/\/+$/, "");
4881
+ }
4881
4882
  function mergeJsonConfigWithRuntimeOverrides(fileConfig, importedConfig) {
4882
4883
  const jsonConfig = buildApitogoConfig({
4883
4884
  ...fileConfig,
@@ -5037,7 +5038,8 @@ async function loadZudokuConfig(configEnv, rootDir) {
5037
5038
  ...loadedConfig.__meta,
5038
5039
  pricingEnabled: isBillingEnabled(jsonConfig),
5039
5040
  authenticationEnabled: isAuthenticationEnabled(manifest),
5040
- previewPlans
5041
+ previewPlans,
5042
+ gatewayBaseUrl: resolvePublishGatewayBaseUrl()
5041
5043
  }
5042
5044
  };
5043
5045
  const transformedConfig = applyAuthenticationHeaderNav(
@@ -6026,6 +6028,7 @@ function manifestFilePath(siteDirectory) {
6026
6028
  var STORED_MANIFEST_KEYS = [
6027
6029
  "authentication",
6028
6030
  "backend",
6031
+ "gateway",
6029
6032
  "projectId"
6030
6033
  ];
6031
6034
  function readPlanItemsFromConfig(config2) {
@@ -6181,12 +6184,6 @@ function extractManifestFromConfig(config2) {
6181
6184
  if (readPricingEnabledFromConfig(config2)) {
6182
6185
  manifest.pricing = { enabled: true };
6183
6186
  }
6184
- const authConfig = config2.authentication;
6185
- if (authConfig?.gateway) {
6186
- manifest.gateway = authConfig.gateway;
6187
- } else if (config2.gateway) {
6188
- manifest.gateway = config2.gateway;
6189
- }
6190
6187
  const landing = readLandingFromConfig(config2);
6191
6188
  if (landing) {
6192
6189
  manifest.landing = landing;
@@ -6226,15 +6223,14 @@ function applyManifestToConfig(config2, manifest) {
6226
6223
  }
6227
6224
  delete next.plans;
6228
6225
  delete next.pricing;
6229
- if (manifest.gateway) {
6230
- const authentication = next.authentication ?? {};
6231
- authentication.gateway = manifest.gateway;
6226
+ if (next.authentication && typeof next.authentication === "object") {
6227
+ const authentication = { ...next.authentication };
6228
+ delete authentication.gateway;
6232
6229
  next.authentication = authentication;
6233
6230
  }
6234
6231
  delete next.siteName;
6235
6232
  delete next.branding;
6236
6233
  delete next.projectName;
6237
- delete next.gateway;
6238
6234
  return next;
6239
6235
  }
6240
6236
  async function readLegacyManifest(_siteDirectory) {
@@ -12537,7 +12533,7 @@ import {
12537
12533
  // package.json
12538
12534
  var package_default = {
12539
12535
  name: "@lukoweb/apitogo",
12540
- version: "0.1.91",
12536
+ version: "0.1.93",
12541
12537
  type: "module",
12542
12538
  sideEffects: [
12543
12539
  "**/*.css",
@@ -15289,7 +15285,8 @@ var viteConfigPlugin = () => {
15289
15285
  rootDir: loadedConfig.__meta.rootDir,
15290
15286
  pricingEnabled: loadedConfig.__meta.pricingEnabled,
15291
15287
  authenticationEnabled: loadedConfig.__meta.authenticationEnabled,
15292
- previewPlans: loadedConfig.__meta.previewPlans
15288
+ previewPlans: loadedConfig.__meta.previewPlans,
15289
+ gatewayBaseUrl: loadedConfig.__meta.gatewayBaseUrl
15293
15290
  };
15294
15291
  return `
15295
15292
  import rawConfig from "${normalizePath(configPath)}";
@@ -16252,6 +16249,9 @@ import path39 from "node:path";
16252
16249
  import { normalizePath as normalizePath2 } from "vite";
16253
16250
  var getLandingModuleImport = (moduleDir, mode) => mode === "internal" ? normalizePath2(path39.join(moduleDir, "../module-landing/src/index.tsx")) : "@lukoweb/apitogo-module-landing";
16254
16251
  var getUserPanelModuleImport = (moduleDir, mode) => mode === "internal" ? normalizePath2(path39.join(moduleDir, "../module-user-panel/src/index.tsx")) : "@lukoweb/apitogo-module-user-panel";
16252
+ var getBillingPluginImport = (moduleDir, mode) => mode === "internal" ? normalizePath2(
16253
+ path39.join(moduleDir, "../plugin-dev-portal-billing/src/index.ts")
16254
+ ) : "@lukoweb/apitogo-plugin-dev-portal-billing";
16255
16255
  var resolveProjectPageImport = (rootDir, pagePath) => normalizePath2(path39.resolve(rootDir, pagePath));
16256
16256
  var viteModulesPlugin = () => {
16257
16257
  const virtualModuleId4 = "virtual:zudoku-modules-plugin";
@@ -16317,10 +16317,15 @@ var viteModulesPlugin = () => {
16317
16317
  `import LandingPage from "${resolveProjectPageImport(config2.__meta.rootDir, landingPagePath)}";`
16318
16318
  );
16319
16319
  }
16320
+ const useBillingDashboard = !dashboardPagePath && userPanel.plans.enabled;
16320
16321
  if (dashboardPagePath) {
16321
16322
  code.push(
16322
16323
  `import UserPanelDashboardPage from "${resolveProjectPageImport(config2.__meta.rootDir, dashboardPagePath)}";`
16323
16324
  );
16325
+ } else if (useBillingDashboard) {
16326
+ code.push(
16327
+ `import { OverviewDashboardPage as UserPanelDashboardPage } from "${getBillingPluginImport(config2.__meta.moduleDir, config2.__meta.mode)}";`
16328
+ );
16324
16329
  }
16325
16330
  if (profilePagePath) {
16326
16331
  code.push(
@@ -16361,7 +16366,7 @@ var viteModulesPlugin = () => {
16361
16366
  ` ...${JSON.stringify(userPanelConfig)},`,
16362
16367
  ` dashboard: {`,
16363
16368
  ` ...${JSON.stringify(userPanelConfig.dashboard)},`,
16364
- ` component: config.modules?.userPanel?.dashboard?.component${dashboardPagePath ? " ?? UserPanelDashboardPage" : ""},`,
16369
+ ` component: config.modules?.userPanel?.dashboard?.component${dashboardPagePath || useBillingDashboard ? " ?? UserPanelDashboardPage" : ""},`,
16365
16370
  ` },`,
16366
16371
  ` userManagement: {`,
16367
16372
  ` ...${JSON.stringify(userPanelConfig.userManagement)},`,
@@ -18687,6 +18692,9 @@ function patchApitogoConfigOpenApiToFile(content, input3 = "./openapi.json") {
18687
18692
  var ACCOUNT_PLANS_PAGE_PATH = "user-panel/AccountPlansPage.tsx";
18688
18693
  var ACCOUNT_PLANS_PAGE = `export { AccountPlansPage as default } from "@lukoweb/apitogo-plugin-dev-portal-billing";
18689
18694
  `;
18695
+ var DASHBOARD_PAGE_PATH = "user-panel/DashboardPage.tsx";
18696
+ var DASHBOARD_PAGE = `export { OverviewDashboardPage as default } from "@lukoweb/apitogo-plugin-dev-portal-billing";
18697
+ `;
18690
18698
  function patchAccountPlansPageConfig(content) {
18691
18699
  if (/plans:\s*\{[^}]*page:/s.test(content)) {
18692
18700
  return content;
@@ -18699,6 +18707,18 @@ function patchAccountPlansPageConfig(content) {
18699
18707
  }`
18700
18708
  );
18701
18709
  }
18710
+ function patchDashboardPageConfig(content) {
18711
+ if (/dashboard:\s*\{[^}]*page:/s.test(content)) {
18712
+ return content;
18713
+ }
18714
+ return content.replace(
18715
+ /dashboard:\s*\{\s*enabled:\s*true\s*\}/,
18716
+ `dashboard: {
18717
+ enabled: true,
18718
+ page: "./user-panel/DashboardPage.tsx",
18719
+ }`
18720
+ );
18721
+ }
18702
18722
  async function ensureAccountPlansPage(outputDirectory) {
18703
18723
  const pagePath = path53.join(outputDirectory, ACCOUNT_PLANS_PAGE_PATH);
18704
18724
  try {
@@ -18710,6 +18730,17 @@ async function ensureAccountPlansPage(outputDirectory) {
18710
18730
  return ACCOUNT_PLANS_PAGE_PATH;
18711
18731
  }
18712
18732
  }
18733
+ async function ensureDashboardPage(outputDirectory) {
18734
+ const pagePath = path53.join(outputDirectory, DASHBOARD_PAGE_PATH);
18735
+ try {
18736
+ await readFile13(pagePath, "utf8");
18737
+ return void 0;
18738
+ } catch {
18739
+ await mkdir10(path53.dirname(pagePath), { recursive: true });
18740
+ await writeFile12(pagePath, DASHBOARD_PAGE);
18741
+ return DASHBOARD_PAGE_PATH;
18742
+ }
18743
+ }
18713
18744
  function patchApitogoConfig(content, options) {
18714
18745
  let patched = content;
18715
18746
  if (options.title) {
@@ -18773,12 +18804,14 @@ async function patchScaffoldedSite(outputDirectory, options) {
18773
18804
  const configContent = await readFile13(configPath, "utf8");
18774
18805
  await writeFile12(
18775
18806
  configPath,
18776
- patchAccountPlansPageConfig(
18777
- patchApitogoConfig(configContent, {
18778
- title,
18779
- openApiUrl: options.openApiUrl,
18780
- logoUrl: options.branding?.logoUrl
18781
- })
18807
+ patchDashboardPageConfig(
18808
+ patchAccountPlansPageConfig(
18809
+ patchApitogoConfig(configContent, {
18810
+ title,
18811
+ openApiUrl: options.openApiUrl,
18812
+ logoUrl: options.branding?.logoUrl
18813
+ })
18814
+ )
18782
18815
  )
18783
18816
  );
18784
18817
  filesWritten.push("apitogo.config.tsx");
@@ -18786,6 +18819,10 @@ async function patchScaffoldedSite(outputDirectory, options) {
18786
18819
  if (accountPlansPage) {
18787
18820
  filesWritten.push(accountPlansPage);
18788
18821
  }
18822
+ const dashboardPage = await ensureDashboardPage(outputDirectory);
18823
+ if (dashboardPage) {
18824
+ filesWritten.push(dashboardPage);
18825
+ }
18789
18826
  return filesWritten;
18790
18827
  }
18791
18828
  async function scaffoldDevPortalSite(options) {
@@ -19138,7 +19175,10 @@ function createDefaultApitogoJsonConfig(options) {
19138
19175
  },
19139
19176
  userPanel: {
19140
19177
  enabled: true,
19141
- dashboard: { enabled: true },
19178
+ dashboard: {
19179
+ enabled: true,
19180
+ page: "./user-panel/DashboardPage.tsx"
19181
+ },
19142
19182
  userManagement: { enabled: true },
19143
19183
  plans: {
19144
19184
  enabled: true,
@@ -19963,6 +20003,8 @@ async function ensureTsconfigJson(dir) {
19963
20003
  var LANDING_PAGE_SOURCE = 'import {\n ApiShowcaseSection,\n FeaturesSection,\n HeroSection,\n LandingShell,\n PlansPreviewSection,\n PricingCtaSection,\n StepsSection,\n} from "@lukoweb/apitogo-module-landing";\n\nexport default function LandingPage() {\n return (\n <LandingShell\n title="Test against a real API before you build one"\n description="Sample API gives you realistic endpoints, authentication, rate limits, and webhooks \u2014 a fully working backend for prototypes, demos, and QA."\n >\n <HeroSection\n hero={{\n badge: "SANDBOX API",\n title: "Test against a real API before you build one",\n description:\n "Sample API gives you realistic endpoints, authentication, rate limits, and webhooks \u2014 a fully working backend for prototypes, demos, and QA. No setup, no infrastructure.",\n }}\n primaryAction={{ label: "Start for free", href: "/signup" }}\n secondaryAction={{ label: "Read the docs", href: "/introduction" }}\n codeExamples={[\n {\n filename: "Create a key",\n language: "bash",\n code: `$ curl -X POST https://api.sampleapi.dev/v1/keys\n# sk_live_9f2c7a1e...`,\n },\n {\n filename: "Call an endpoint",\n language: "bash",\n code: `$ curl https://api.sampleapi.dev/v1/users/42 \\\\\n -H "Authorization: Bearer sk_live_9f2c..."\n# { "id": 42, "name": "Priya Shah", "plan": "pro" }`,\n },\n ]}\n />\n <FeaturesSection\n section={{\n label: "Everything a real API has",\n title: "Built for testing, not just browsing",\n description:\n "Realistic data, live auth, rate limits, and webhooks \u2014 so you can ship with confidence.",\n }}\n features={[\n {\n title: "Realistic mock data",\n description:\n "Users, orders, products, and posts with relationships and pagination, not lorem ipsum.",\n icon: "boxes",\n },\n {\n title: "Real auth flow",\n description:\n "Every request checks a live API key, so you can test error handling and expiry.",\n icon: "shield-check",\n },\n {\n title: "Rate limiting",\n description:\n "Per-plan request limits and 429 responses, so you can build and test backoff logic.",\n icon: "gauge",\n },\n {\n title: "Webhooks",\n description:\n "Trigger test events and inspect delivered payloads and retries.",\n icon: "webhook",\n },\n {\n title: "OpenAPI & docs",\n description:\n "A full OpenAPI spec and reference docs for every endpoint.",\n icon: "book-open",\n },\n {\n title: "Usage analytics",\n description:\n "Track requests, latency, and errors per key from your dashboard.",\n icon: "activity",\n },\n ]}\n />\n <StepsSection\n steps={[\n {\n step: "01",\n title: "Create an account",\n description:\n "Sign up and land straight in your dashboard \u2014 no approval queue.",\n },\n {\n step: "02",\n title: "Generate a key",\n description:\n "Every key is scoped and rate-limited to your plan from the moment it\'s created.",\n },\n {\n step: "03",\n title: "Call any endpoint",\n description:\n "Users, orders, products, posts, and webhooks \u2014 all documented, all live.",\n },\n ]}\n />\n <ApiShowcaseSection\n apiShowcase={{\n title: "Sample endpoints",\n subtitle:\n "A slice of what\'s available \u2014 see the full reference in the docs.",\n browseAllHref: "/api",\n browseAllLabel: "Browse all APIs",\n layout: "list",\n items: [\n {\n name: "List paginated users",\n path: "/v1/users",\n method: "GET",\n summary: "List paginated users",\n },\n {\n name: "Create a mock order",\n path: "/v1/orders",\n method: "POST",\n summary: "Create a mock order",\n },\n {\n name: "Fetch one product",\n path: "/v1/products/{id}",\n method: "GET",\n summary: "Fetch one product",\n },\n {\n name: "List posts with comments",\n path: "/v1/posts",\n method: "GET",\n summary: "List posts with comments",\n },\n {\n name: "Fire a test webhook event",\n path: "/v1/webhooks/test",\n method: "POST",\n summary: "Fire a test webhook event",\n },\n {\n name: "Delete a mock order",\n path: "/v1/orders/{id}",\n method: "DELETE",\n summary: "Delete a mock order",\n },\n ],\n }}\n />\n <PlansPreviewSection\n plans={[\n {\n name: "Free",\n price: "$0",\n unit: "per month",\n blurb: "1,000 requests/mo, community support.",\n },\n {\n name: "Developer",\n price: "$19",\n unit: "per month",\n blurb: "50,000 requests/mo, webhooks included.",\n featured: true,\n },\n {\n name: "Team",\n price: "$79",\n unit: "per month",\n blurb: "500,000 requests/mo, 5 team seats.",\n },\n {\n name: "Enterprise",\n price: "Custom",\n unit: "talk to us",\n blurb: "Unlimited requests, SSO, SLA.",\n },\n ]}\n />\n <PricingCtaSection\n variant="banner"\n pricingCta={{\n title: "Start testing in minutes",\n description:\n "Free tier includes 1,000 requests a month \u2014 no card required.",\n primaryAction: { label: "Start for free", href: "/signup" },\n }}\n />\n </LandingShell>\n );\n}\n';
19964
20004
  var ACCOUNT_PLANS_PAGE_SOURCE = `export { AccountPlansPage as default } from "@lukoweb/apitogo-plugin-dev-portal-billing";
19965
20005
  `;
20006
+ var DASHBOARD_PAGE_SOURCE = `export { OverviewDashboardPage as default } from "@lukoweb/apitogo-plugin-dev-portal-billing";
20007
+ `;
19966
20008
  var fileExists2 = async (filePath) => {
19967
20009
  try {
19968
20010
  await access7(filePath, constants.F_OK);
@@ -19982,6 +20024,11 @@ async function ensurePortalStubFiles(dir) {
19982
20024
  await mkdir11(path57.join(dir, "user-panel"), { recursive: true });
19983
20025
  await writeFile14(plansPath, ACCOUNT_PLANS_PAGE_SOURCE, "utf8");
19984
20026
  }
20027
+ const dashboardPath = path57.join(dir, "user-panel", "DashboardPage.tsx");
20028
+ if (!await fileExists2(dashboardPath)) {
20029
+ await mkdir11(path57.join(dir, "user-panel"), { recursive: true });
20030
+ await writeFile14(dashboardPath, DASHBOARD_PAGE_SOURCE, "utf8");
20031
+ }
19985
20032
  await ensurePublicBrandingFiles(dir);
19986
20033
  }
19987
20034
  async function ensurePublicBrandingFiles(dir) {
@@ -58,6 +58,9 @@ export interface DevPortalLocalManifest {
58
58
  limitsJson?: string;
59
59
  publicHealthActionKey?: string;
60
60
  publicRoutes?: string[];
61
+ cors?: {
62
+ allowedOrigins?: string[];
63
+ };
61
64
  };
62
65
  projectId?: string;
63
66
  landing?: LandingManifestInput;
@@ -13,6 +13,7 @@ export type ConfigWithMeta = ZudokuConfig & {
13
13
  pricingEnabled?: boolean;
14
14
  authenticationEnabled?: boolean;
15
15
  previewPlans?: DevPortalPreviewPlan[];
16
+ gatewayBaseUrl?: string;
16
17
  };
17
18
  __resolvedModules: ResolvedModulesConfig;
18
19
  };
@@ -89,6 +89,10 @@ export declare const DevPortalLocalManifestSchema: z.ZodObject<{
89
89
  oidcAudiences: z.ZodOptional<z.ZodArray<z.ZodString>>;
90
90
  limitsJson: z.ZodOptional<z.ZodString>;
91
91
  publicHealthActionKey: z.ZodOptional<z.ZodString>;
92
+ publicRoutes: z.ZodOptional<z.ZodArray<z.ZodString>>;
93
+ cors: z.ZodOptional<z.ZodObject<{
94
+ allowedOrigins: z.ZodOptional<z.ZodArray<z.ZodString>>;
95
+ }, z.core.$strip>>;
92
96
  }, z.core.$strip>>;
93
97
  projectId: z.ZodOptional<z.ZodString>;
94
98
  landing: z.ZodOptional<z.ZodObject<{
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react";
1
+ import { type ReactNode } from "react";
2
2
  export declare const LandingLayout: ({ children }: {
3
3
  children?: ReactNode;
4
4
  }) => import("react/jsx-runtime").JSX.Element;
@@ -25,10 +25,17 @@ metadata, with optional `.env` overrides for secrets and local tweaks.
25
25
  "authentication": {
26
26
  "enabled": true,
27
27
  "type": "dev-portal",
28
- "apiBaseUrl": "https://your-dev-portal-api.example.com",
29
- "gateway": {
30
- "authMode": "bringYourOwnOidc",
31
- "oidcMetadataUrl": "https://login.example.com/.well-known/openid-configuration"
28
+ "apiBaseUrl": "https://your-dev-portal-api.example.com"
29
+ },
30
+ "gateway": {
31
+ "authMode": "bringYourOwnOidc",
32
+ "oidcMetadataUrl": "https://login.example.com/.well-known/openid-configuration",
33
+ "publicRoutes": ["/health"],
34
+ "cors": {
35
+ "allowedOrigins": [
36
+ "https://myapp.example.com",
37
+ "http://localhost:5173"
38
+ ]
32
39
  }
33
40
  },
34
41
  "modules": {
@@ -60,6 +67,36 @@ metadata, with optional `.env` overrides for secrets and local tweaks.
60
67
  `modules.userPanel.plans.items`. Add plans with `apitogo config` or edit the manifest by hand.
61
68
  See [Gateway plans](./plans.md). Legacy root `plans` is migrated on load.
62
69
  - **Docs / API keys:** configure under `modules.docs` and `modules.userPanel.apiKeys` only.
70
+ - **Gateway:** top-level `gateway` (not under `authentication`). See [Gateway CORS](#gateway-cors)
71
+ and [Gateway plans](./plans.md).
72
+
73
+ ## Gateway CORS
74
+
75
+ Browser calls from your developer portal (and any other SPA) to the API gateway are cross-origin.
76
+ APItoGo always allows the portal site origin. Add extra origins under `gateway.cors.allowedOrigins`
77
+ when tenant apps or local frontends call the gateway:
78
+
79
+ ```json
80
+ {
81
+ "gateway": {
82
+ "cors": {
83
+ "allowedOrigins": [
84
+ "https://myapp.example.com",
85
+ "http://localhost:5173"
86
+ ]
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ | Rule | Detail |
93
+ | ---- | ------ |
94
+ | Portal origin | Always allowed by the platform (`https://{your-portal-host}`). Do not list it here. |
95
+ | Extra origins | Absolute `http://` or `https://` origins only — no path, query, or fragment. |
96
+ | Wildcard | `"*"` is allowed (replaces the list; credentials stay off). |
97
+ | Empty / omitted | Portal playground still works; only extras are missing. |
98
+
99
+ Apply with `apitogo apply` / `publish_dev_portal` so APIM policies pick up the change.
63
100
 
64
101
  ## Environment variables
65
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lukoweb/apitogo",
3
- "version": "0.1.91",
3
+ "version": "0.1.93",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -297,28 +297,17 @@ function firstApiInput(config: ApitogoJsonConfig): string | undefined {
297
297
  return undefined;
298
298
  }
299
299
 
300
- function mergeAuthenticationGateway(
301
- config: ApitogoJsonConfig,
302
- ): Record<string, unknown> | undefined {
300
+ /** Keep gateway at config root; strip any nested authentication.gateway. */
301
+ function normalizeGatewayPlacement(config: ApitogoJsonConfig): void {
303
302
  const authentication = asRecord(config.authentication);
304
303
  if (!authentication) {
305
- return undefined;
304
+ return;
306
305
  }
307
306
 
308
- const authGateway = asRecord(authentication.gateway);
309
- const rootGateway = asRecord(config.gateway);
310
-
311
- if (!authGateway && !rootGateway) {
312
- return authentication;
307
+ if ("gateway" in authentication) {
308
+ delete authentication.gateway;
309
+ config.authentication = authentication;
313
310
  }
314
-
315
- return {
316
- ...authentication,
317
- gateway: {
318
- ...rootGateway,
319
- ...authGateway,
320
- },
321
- };
322
311
  }
323
312
 
324
313
  function ensureModules(config: ApitogoJsonConfig): Record<string, unknown> {
@@ -403,11 +392,10 @@ export function normalizeApitogoConfig(
403
392
  }
404
393
  normalized.site = site;
405
394
 
406
- const authentication = mergeAuthenticationGateway(normalized);
395
+ const authentication = asRecord(normalized.authentication);
407
396
  if (authentication) {
408
- normalized.authentication = authentication;
397
+ normalizeGatewayPlacement(normalized);
409
398
  }
410
- delete normalized.gateway;
411
399
 
412
400
  const modules = ensureModules(normalized);
413
401
  const legacyDocs = asRecord(normalized.docs);
@@ -654,6 +642,9 @@ export function deriveManifestFields(
654
642
  continue;
655
643
  }
656
644
  if (key === "gateway") {
645
+ if (normalized.gateway !== undefined) {
646
+ manifest.gateway = normalized.gateway;
647
+ }
657
648
  continue;
658
649
  }
659
650
  if (key === "pricing") {
@@ -686,11 +677,6 @@ export function deriveManifestFields(
686
677
  }
687
678
  }
688
679
 
689
- const authentication = asRecord(normalized.authentication);
690
- if (authentication?.gateway) {
691
- manifest.gateway = authentication.gateway;
692
- }
693
-
694
680
  return manifest;
695
681
  }
696
682
 
@@ -45,6 +45,8 @@ export type ConfigWithMeta = ZudokuConfig & {
45
45
  pricingEnabled?: boolean;
46
46
  authenticationEnabled?: boolean;
47
47
  previewPlans?: DevPortalPreviewPlan[];
48
+ /** Live gateway URL baked in at publish/build (`APITOGO_GATEWAY_URL`). */
49
+ gatewayBaseUrl?: string;
48
50
  };
49
51
  __resolvedModules: ResolvedModulesConfig;
50
52
  };
@@ -69,6 +71,15 @@ const zudokuConfigFiles = [
69
71
 
70
72
  const serverConfigOutputFiles = ["apitogo.config.js", "zudoku.config.js"];
71
73
 
74
+ function resolvePublishGatewayBaseUrl(): string | undefined {
75
+ const raw = process.env.APITOGO_GATEWAY_URL?.trim();
76
+ if (!raw) {
77
+ return undefined;
78
+ }
79
+
80
+ return raw.replace(/\/+$/, "");
81
+ }
82
+
72
83
  export function mergeJsonConfigWithRuntimeOverrides(
73
84
  fileConfig: Record<string, unknown>,
74
85
  importedConfig: ZudokuConfig,
@@ -304,6 +315,7 @@ export async function loadZudokuConfig(
304
315
  pricingEnabled: isBillingEnabled(jsonConfig),
305
316
  authenticationEnabled: isAuthenticationEnabled(manifest),
306
317
  previewPlans,
318
+ gatewayBaseUrl: resolvePublishGatewayBaseUrl(),
307
319
  },
308
320
  };
309
321
  const transformedConfig = applyAuthenticationHeaderNav(
@@ -140,6 +140,12 @@ export const DevPortalLocalManifestSchema = z.object({
140
140
  oidcAudiences: z.array(z.string()).optional(),
141
141
  limitsJson: z.string().optional(),
142
142
  publicHealthActionKey: z.string().optional(),
143
+ publicRoutes: z.array(z.string()).optional(),
144
+ cors: z
145
+ .object({
146
+ allowedOrigins: z.array(z.string()).optional(),
147
+ })
148
+ .optional(),
143
149
  })
144
150
  .optional(),
145
151
  projectId: z.string().optional(),
@@ -1,5 +1,4 @@
1
1
  import type { LucideIcon } from "lucide-react";
2
- import { Link } from "react-router";
3
2
  import {
4
3
  isHeaderNavGroup,
5
4
  type HeaderNavGroup,
@@ -18,6 +17,7 @@ import {
18
17
  NavigationMenuTrigger,
19
18
  } from "../ui/NavigationMenu.js";
20
19
  import { cn } from "../util/cn.js";
20
+ import { AnchorLink } from "./AnchorLink.js";
21
21
  import { useZudoku } from "./context/ZudokuContext.js";
22
22
  import { shouldShowItem } from "./navigation/utils.js";
23
23
 
@@ -28,7 +28,7 @@ const NavLinkItem = ({ item }: { item: HeaderNavLinkItem }) => {
28
28
  const Icon = item.icon as LucideIcon | undefined;
29
29
  return (
30
30
  <NavigationMenuLink asChild>
31
- <Link
31
+ <AnchorLink
32
32
  to={item.to}
33
33
  target={item.target}
34
34
  rel={item.target === "_blank" ? "noopener noreferrer" : undefined}
@@ -45,7 +45,7 @@ const NavLinkItem = ({ item }: { item: HeaderNavLinkItem }) => {
45
45
  {item.description}
46
46
  </p>
47
47
  )}
48
- </Link>
48
+ </AnchorLink>
49
49
  </NavigationMenuLink>
50
50
  );
51
51
  };
@@ -117,7 +117,7 @@ const HeaderNavItemComponent = ({
117
117
  return (
118
118
  <NavigationMenuItem>
119
119
  <NavigationMenuLink asChild>
120
- <Link
120
+ <AnchorLink
121
121
  to={item.to}
122
122
  target={item.target}
123
123
  rel={item.target === "_blank" ? "noopener noreferrer" : undefined}
@@ -125,7 +125,7 @@ const HeaderNavItemComponent = ({
125
125
  >
126
126
  {Icon && <Icon size={16} />}
127
127
  {item.label}
128
- </Link>
128
+ </AnchorLink>
129
129
  </NavigationMenuLink>
130
130
  </NavigationMenuItem>
131
131
  );
@@ -1,21 +1,27 @@
1
1
  import { TooltipProvider } from "@lukoweb/apitogo/ui/Tooltip.js";
2
- import type { ReactNode } from "react";
3
- import { Suspense } from "react";
2
+ import { type ReactNode, Suspense } from "react";
4
3
  import { Outlet } from "react-router";
4
+ import { useScrollToAnchor } from "../util/useScrollToAnchor.js";
5
+ import { useScrollToTop } from "../util/useScrollToTop.js";
5
6
  import { Footer } from "./Footer.js";
6
7
  import { Header } from "./Header.js";
7
8
  import { Slot } from "./Slot.js";
8
9
 
9
- export const LandingLayout = ({ children }: { children?: ReactNode }) => (
10
- <TooltipProvider>
11
- <Slot.Target name="layout-before-head" />
12
- <Suspense>
13
- <Header />
14
- </Suspense>
15
- <Slot.Target name="layout-after-head" />
16
- <main className="min-h-[calc(100dvh-var(--top-header-height))] w-full">
17
- {children ?? <Outlet />}
18
- </main>
19
- <Footer />
20
- </TooltipProvider>
21
- );
10
+ export const LandingLayout = ({ children }: { children?: ReactNode }) => {
11
+ useScrollToAnchor();
12
+ useScrollToTop();
13
+
14
+ return (
15
+ <TooltipProvider>
16
+ <Slot.Target name="layout-before-head" />
17
+ <Suspense>
18
+ <Header />
19
+ </Suspense>
20
+ <Slot.Target name="layout-after-head" />
21
+ <main className="min-h-[calc(100dvh-var(--top-header-height))] w-full">
22
+ {children ?? <Outlet />}
23
+ </main>
24
+ <Footer />
25
+ </TooltipProvider>
26
+ );
27
+ };
@@ -28,6 +28,7 @@ import {
28
28
  DrawerTitle,
29
29
  DrawerTrigger,
30
30
  } from "../ui/Drawer.js";
31
+ import { AnchorLink } from "./AnchorLink.js";
31
32
  import { useCurrentNavigation, useZudoku } from "./context/ZudokuContext.js";
32
33
  import { PoweredByZudoku } from "./navigation/PoweredByZudoku.js";
33
34
  import { getFirstMatchingPath, shouldShowItem } from "./navigation/utils.js";
@@ -44,7 +45,7 @@ const MobileHeaderNavLink = ({
44
45
  }) => {
45
46
  const Icon = item.icon as LucideIcon | undefined;
46
47
  return (
47
- <Link
48
+ <AnchorLink
48
49
  to={item.to}
49
50
  target={item.target}
50
51
  rel={item.target === "_blank" ? "noopener noreferrer" : undefined}
@@ -53,7 +54,7 @@ const MobileHeaderNavLink = ({
53
54
  >
54
55
  {Icon && <Icon size={16} />}
55
56
  {item.label}
56
- </Link>
57
+ </AnchorLink>
57
58
  );
58
59
  };
59
60
 
@@ -68,7 +69,7 @@ const MobileHeaderNavItem = ({
68
69
  const Icon = item.icon as LucideIcon | undefined;
69
70
  return (
70
71
  <li className="w-full">
71
- <Link
72
+ <AnchorLink
72
73
  to={item.to}
73
74
  target={item.target}
74
75
  rel={item.target === "_blank" ? "noopener noreferrer" : undefined}
@@ -77,7 +78,7 @@ const MobileHeaderNavItem = ({
77
78
  >
78
79
  {Icon && <Icon size={16} />}
79
80
  {item.label}
80
- </Link>
81
+ </AnchorLink>
81
82
  </li>
82
83
  );
83
84
  }
@@ -57,6 +57,11 @@ export const useScrollToAnchor = () => {
57
57
  return;
58
58
  }
59
59
 
60
+ if (scrollToHash(location.hash)) {
61
+ initialScrolled.current = true;
62
+ return;
63
+ }
64
+
60
65
  const observer = new MutationObserver((_, obs) => {
61
66
  if (!scrollToHash(location.hash)) return;
62
67
  initialScrolled.current = true;
@@ -69,6 +69,7 @@ const viteConfigPlugin = (): Plugin => {
69
69
  pricingEnabled: loadedConfig.__meta.pricingEnabled,
70
70
  authenticationEnabled: loadedConfig.__meta.authenticationEnabled,
71
71
  previewPlans: loadedConfig.__meta.previewPlans,
72
+ gatewayBaseUrl: loadedConfig.__meta.gatewayBaseUrl,
72
73
  };
73
74
 
74
75
  return `
@@ -14,6 +14,13 @@ const getUserPanelModuleImport = (moduleDir: string, mode: string) =>
14
14
  ? normalizePath(path.join(moduleDir, "../module-user-panel/src/index.tsx"))
15
15
  : "@lukoweb/apitogo-module-user-panel";
16
16
 
17
+ const getBillingPluginImport = (moduleDir: string, mode: string) =>
18
+ mode === "internal"
19
+ ? normalizePath(
20
+ path.join(moduleDir, "../plugin-dev-portal-billing/src/index.ts"),
21
+ )
22
+ : "@lukoweb/apitogo-plugin-dev-portal-billing";
23
+
17
24
  const resolveProjectPageImport = (rootDir: string, pagePath: string) =>
18
25
  normalizePath(path.resolve(rootDir, pagePath));
19
26
 
@@ -91,10 +98,17 @@ const viteModulesPlugin = (): Plugin => {
91
98
  );
92
99
  }
93
100
 
101
+ const useBillingDashboard =
102
+ !dashboardPagePath && userPanel.plans.enabled;
103
+
94
104
  if (dashboardPagePath) {
95
105
  code.push(
96
106
  `import UserPanelDashboardPage from "${resolveProjectPageImport(config.__meta.rootDir, dashboardPagePath)}";`,
97
107
  );
108
+ } else if (useBillingDashboard) {
109
+ code.push(
110
+ `import { OverviewDashboardPage as UserPanelDashboardPage } from "${getBillingPluginImport(config.__meta.moduleDir, config.__meta.mode)}";`,
111
+ );
98
112
  }
99
113
 
100
114
  if (profilePagePath) {
@@ -146,7 +160,7 @@ const viteModulesPlugin = (): Plugin => {
146
160
  ` ...${JSON.stringify(userPanelConfig)},`,
147
161
  ` dashboard: {`,
148
162
  ` ...${JSON.stringify(userPanelConfig.dashboard)},`,
149
- ` component: config.modules?.userPanel?.dashboard?.component${dashboardPagePath ? " ?? UserPanelDashboardPage" : ""},`,
163
+ ` component: config.modules?.userPanel?.dashboard?.component${dashboardPagePath || useBillingDashboard ? " ?? UserPanelDashboardPage" : ""},`,
150
164
  ` },`,
151
165
  ` userManagement: {`,
152
166
  ` ...${JSON.stringify(userPanelConfig.userManagement)},`,