@lukoweb/apitogo 0.2.26 → 0.2.29

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
@@ -6617,6 +6617,12 @@ function resolveConfigV2(config2, configPath) {
6617
6617
  }
6618
6618
  };
6619
6619
  }
6620
+ if (resolved.gateway && resolved.plans?.enabled && resolved.gateway.policies.access === void 0) {
6621
+ resolved.gateway.policies = {
6622
+ ...resolved.gateway.policies,
6623
+ access: { jwt: false, apiKey: true }
6624
+ };
6625
+ }
6620
6626
  return resolved;
6621
6627
  }
6622
6628
 
@@ -7933,6 +7939,7 @@ function publishedManifest(manifest, plan2) {
7933
7939
  };
7934
7940
  }
7935
7941
  var DEFAULT_SCOPES = ["openid", "profile", "email"];
7942
+ var USER_PANEL_PLANS_PLACEHOLDER_PATH = "your-plan";
7936
7943
  function planPortal(profile, manifest) {
7937
7944
  const skipped = [];
7938
7945
  const skip = (piece, reason) => void skipped.push({ piece, reason });
@@ -8023,9 +8030,14 @@ function buildSiteConfig(resolved, plan2, theme, extras = {}) {
8023
8030
  modules: {
8024
8031
  docs: { enabled: plan2.docs },
8025
8032
  // The plans pages (pricing, choosing a plan) exist only when billing is on, and the plans service is what makes it so.
8033
+ // Switching billing on also makes the user panel register its own placeholder plans page, at `/account/<path>`.
8034
+ // Its default path is `plans`, which is where the billing plugin puts the real page (subscribe, switch, cancel),
8035
+ // and the placeholder won: the "Change plan" link opened an empty page. So the placeholder gets its own path.
8026
8036
  userPanel: {
8027
8037
  enabled: plan2.userPanel,
8028
- ...plan2.plansPlugin ? { plans: { enabled: true } } : {}
8038
+ ...plan2.plansPlugin ? {
8039
+ plans: { enabled: true, path: USER_PANEL_PLANS_PLACEHOLDER_PATH }
8040
+ } : {}
8029
8041
  }
8030
8042
  },
8031
8043
  ...plan2.docs ? {
@@ -9968,7 +9980,7 @@ import {
9968
9980
  // package.json
9969
9981
  var package_default = {
9970
9982
  name: "@lukoweb/apitogo",
9971
- version: "0.2.26",
9983
+ version: "0.2.29",
9972
9984
  type: "module",
9973
9985
  sideEffects: [
9974
9986
  "**/*.css",
@@ -28944,6 +28956,26 @@ async function deployV2(options, deps) {
28944
28956
  }
28945
28957
  results.push(await deployPortal(options, deps, projectId));
28946
28958
  }
28959
+ const gatewayAt = results.findIndex(
28960
+ (result) => result.service === "gateway" && result.status !== "skipped"
28961
+ );
28962
+ const portalDeployed = results.some(
28963
+ (result) => result.service === "portal" && result.url !== void 0
28964
+ );
28965
+ const gateway = results[gatewayAt];
28966
+ if (gateway && portalDeployed) {
28967
+ const again = await deployGatewayService(options, deps, projectId, false);
28968
+ if (again.steps.some((step2) => !step2.skipped))
28969
+ results[gatewayAt] = {
28970
+ ...gateway,
28971
+ status: "deployed",
28972
+ url: again.url ?? gateway.url,
28973
+ notes: [
28974
+ ...gateway.notes ?? [],
28975
+ "browser access updated for the portal address"
28976
+ ]
28977
+ };
28978
+ }
28947
28979
  if (results.length === 0) {
28948
28980
  throw new DeployError(
28949
28981
  "NOTHING_TO_DEPLOY",
@@ -0,0 +1,133 @@
1
+ ---
2
+ title: "Walkthrough: from an empty folder to a customer's first call"
3
+ description:
4
+ Build a portal with sign-up, a protected API, and plans, deploy it, and follow one customer from
5
+ sign-up to an API key and a first call.
6
+ sidebar_icon: route
7
+ ---
8
+
9
+ This page follows one project from an empty folder to a customer calling the protected API with
10
+ their own key. Every step is a command you run, or something your customer does in the portal.
11
+
12
+ ```text
13
+ you: init ─► add identity ─► add gateway ─► add plans ─► project create ─► deploy
14
+ customer: open the portal ─► create an account ─► confirm the email ─► sign in
15
+ ─► subscribe to the Free plan ─► create an API key ─► call the API with the key
16
+ you: teardown (when it was a test)
17
+ ```
18
+
19
+ ## What you need
20
+
21
+ - Node.js 20 or later
22
+ - An APItoGo account, signed in for the CLI (see [Install the CLI](./setup.md))
23
+ - An API that is reachable over HTTPS. This walkthrough uses `https://httpbin.org` as a stand-in.
24
+
25
+ ## 1. Create the project folder
26
+
27
+ ```bash
28
+ npx @lukoweb/apitogo@latest init my-portal --yes --name my-portal
29
+ cd my-portal
30
+ ```
31
+
32
+ `init` creates a config version 2 project with a portal, and installs the CLI into it. The commands
33
+ below use the installed CLI through `npx apitogo`.
34
+
35
+ ## 2. Add the services
36
+
37
+ ```bash
38
+ npx apitogo add identity
39
+ npx apitogo add gateway --origin proxy --url https://httpbin.org --auth identity --create-spec
40
+ npx apitogo add plans --template free --billing none
41
+ ```
42
+
43
+ | Service | What it gives you |
44
+ | -------- | ----------------------------------------------------------------------------------------------- |
45
+ | identity | Sign-up, sign-in, email confirmation and password reset for your customers |
46
+ | gateway | The address your customers call. It forwards to your API and applies the plan's limits |
47
+ | plans | The plan catalog, subscriptions, API keys and usage. The Free template limits calls (see below) |
48
+
49
+ `--create-spec` writes a small OpenAPI file with one `GET /health` operation, so the gateway has
50
+ something to expose. For a real API, point `gateway.specs` at your own OpenAPI file instead.
51
+
52
+ The Free template in `plans.json` allows 3 calls per minute and 1000 calls per 30 days. Change the
53
+ numbers in `plans.json` and deploy again to change the limits.
54
+
55
+ With plans in the project, customers call the gateway with their API key alone. Their login token is
56
+ not needed. To require both, set `gateway.policies.access` yourself. A value you set is never
57
+ replaced.
58
+
59
+ ## 3. Create the platform project and deploy
60
+
61
+ ```bash
62
+ npx apitogo project create
63
+ npx apitogo deploy
64
+ ```
65
+
66
+ `deploy` runs the services in a fixed order and prints what it did:
67
+
68
+ ```text
69
+ identity: deployed (https://id.apitogo.com/<project>)
70
+ gateway: deployed (https://free-api.apitogo.com/p<project>/), 1 operation(s)
71
+ browser access updated for the portal address
72
+ plans: deployed (https://portal.<your-portal-address>)
73
+ portal: deployed to production (https://<your-portal-address>)
74
+ ```
75
+
76
+ The portal's address is only known once the portal is published. The gateway only accepts browser
77
+ calls from addresses it knows, so `deploy` sends the gateway its list again after the portal. One
78
+ `deploy` is enough: you do not need to run it twice.
79
+
80
+ Running `deploy` again changes only what changed. Use `npx apitogo status` to see each service and
81
+ `npx apitogo plan` to see what the next deploy would do.
82
+
83
+ ## 4. The customer's side
84
+
85
+ Open the portal address and follow the steps your customer follows:
86
+
87
+ 1. Choose **Log in**. The portal sends the customer to your project's sign-in page.
88
+ 2. Choose **Create an account**, enter an email address and a password, and confirm the email. The
89
+ link in the email works for 24 hours.
90
+ 3. Sign in. The customer lands back in your portal.
91
+ 4. Open **Plans** and subscribe to **Free**.
92
+ 5. Open **API keys**, create a key, and copy it.
93
+ 6. Call the API with the key in the `Ocp-Apim-Subscription-Key` header:
94
+
95
+ ```bash
96
+ curl -i -H "Ocp-Apim-Subscription-Key: <the key>" \
97
+ https://free-api.apitogo.com/p<project>/health
98
+ ```
99
+
100
+ What the gateway answers:
101
+
102
+ | Request | Answer |
103
+ | ------------------------------------- | ---------------------------------------------------- |
104
+ | A valid key | The answer of your API |
105
+ | No key, a wrong key, or a revoked key | `401` |
106
+ | More calls than the plan allows | `429`. A quota answer carries a `Retry-After` header |
107
+
108
+ The customer sees their calls on the **Usage** page. See
109
+ [Your customers' account pages](../guides/customer-account.md).
110
+
111
+ ## 5. Remove a test project
112
+
113
+ ```bash
114
+ npx apitogo teardown --yes
115
+ ```
116
+
117
+ This deletes the platform project: the gateway, the identity and its users, the portal, its address
118
+ and certificate, and the project's email domain. Your local files stay.
119
+
120
+ ## Things worth knowing
121
+
122
+ - **One identity per project.** A customer who signs up on one project cannot sign in to another.
123
+ - **Sign-up needs a real email address.** Confirmation and password-reset emails are sent to it. For
124
+ tests, an address with a suffix such as `you+test1@example.com` usually reaches your own mailbox.
125
+ - **Secrets stay out of the config.** A value such as an origin key is written as `secret:NAME` and
126
+ set with `npx apitogo secrets`.
127
+
128
+ ## Related
129
+
130
+ - [Install the CLI](./setup.md)
131
+ - [CLI commands](./commands.md)
132
+ - [Protect an API with the gateway](../configuration/gateway.md)
133
+ - [Your customers' account pages](../guides/customer-account.md)
@@ -0,0 +1,71 @@
1
+ ---
2
+ title: Your customers' account pages
3
+ description:
4
+ "What a customer sees in your portal once plans are on (plans, API keys and usage) and how each
5
+ page relates to the gateway."
6
+ sidebar_icon: user-round
7
+ ---
8
+
9
+ When a project has the plans service, the portal gets an account area for signed-in customers. It
10
+ needs no code from you. This page describes what each part does, so you can support your customers
11
+ and know what to check when something looks wrong.
12
+
13
+ ## The pages
14
+
15
+ | Page | What the customer does |
16
+ | ------------ | -------------------------------------------------------- |
17
+ | **Plans** | Compares the plans from your `plans.json` and subscribes |
18
+ | **API keys** | Creates, lists and revokes keys |
19
+ | **Usage** | Sees the calls made with their keys |
20
+ | **Settings** | Manages the account details |
21
+
22
+ The pages appear when the plans service is part of the portal. A project with identity only has
23
+ sign-in but no plans, keys or usage.
24
+
25
+ ## Plans
26
+
27
+ The plans come from `plans.json`. A plan has a name, limits, and optionally a price. The customer
28
+ subscribes to a plan and may hold more than one. Unsubscribing from a plan stops that plan's keys
29
+ from working and keeps the customer's other plans.
30
+
31
+ Free plans work without any payment. For paid plans see the `plans.billing` setting.
32
+
33
+ ## API keys
34
+
35
+ A key is created on the API keys page. A customer who no longer has a key can create a new one and
36
+ revoke the old one.
37
+
38
+ - A key works for calls to your gateway address, in the `Ocp-Apim-Subscription-Key` header.
39
+ - The limits of the plan the key belongs to apply to every call made with it.
40
+ - A revoked key stops working at once, and the next call with it is answered with `401`.
41
+
42
+ ## Usage
43
+
44
+ The usage page shows, for a period of 24 hours, 7, 30 or 90 days:
45
+
46
+ - the number of requests
47
+ - the average latency
48
+ - the error rate
49
+ - the requests by endpoint
50
+ - the requests by status code
51
+
52
+ A call is counted when it is made with a customer's key. A call without a key, or with a wrong key,
53
+ is refused by the gateway and does not appear.
54
+
55
+ Answers of `400` and above count as errors, whoever caused them. If your API answers `404` to a path
56
+ the customer calls, those calls show as errors in the usage page.
57
+
58
+ ## When something looks wrong
59
+
60
+ | What the customer sees | What to check |
61
+ | --------------------------------------------- | ------------------------------------------------------------------------------------------------- |
62
+ | `401` with a key that should work | The key was revoked, or the plan was cancelled. Check the key on the API keys page. |
63
+ | `401` on a project you set up by hand | `gateway.policies.access`: when it also requires a login token, a key alone is refused. |
64
+ | `429` | The plan's limit was reached. The rate limit renews each period; the quota carries `Retry-After`. |
65
+ | The usage page shows nothing after real calls | Calls only count when made with a key. Check that the gateway has been deployed with plans. |
66
+ | Calls from the portal's own pages are blocked | Run `npx apitogo deploy` again. The gateway lists the portal's address after the portal exists. |
67
+
68
+ ## Related
69
+
70
+ - [Walkthrough: from an empty folder to a customer's first call](../cli/walkthrough.md)
71
+ - [Protect an API with the gateway](../configuration/gateway.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lukoweb/apitogo",
3
- "version": "0.2.26",
3
+ "version": "0.2.29",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -1,188 +1,202 @@
1
- import path from "node:path";
2
- import { layoutRoot, type StatePaths, statePathsFor } from "./paths.js";
3
- import type { ConfigV2, Layout } from "./schema.js";
4
-
5
- export { layoutRoot };
6
-
7
- /** A path relative to the config file, made absolute. A leading `!` (negated glob) is preserved. */
8
- function fromConfig(configDir: string, value: string): string {
9
- return value.startsWith("!")
10
- ? `!${path.resolve(configDir, value.slice(1))}`
11
- : path.resolve(configDir, value);
12
- }
13
-
14
- export type ResolvedPortal = {
15
- enabled: boolean;
16
- dir: string;
17
- name: string;
18
- docs: {
19
- enabled: boolean;
20
- provider: "apitogo" | "external";
21
- /** Absolute globs. Empty for an external docs provider. */
22
- content: string[];
23
- url?: string;
24
- };
25
- console: { enabled: boolean };
26
- theme: string;
27
- requireAuth: boolean | { exclude: string[] };
28
- search: "pagefind" | "inkeep" | "none";
29
- };
30
-
31
- export type ResolvedGateway = {
32
- enabled: boolean;
33
- provider: string;
34
- origin: NonNullable<ConfigV2["gateway"]>["origin"];
35
- /** Absolute paths. */
36
- specs: string[];
37
- auth?: NonNullable<ConfigV2["gateway"]>["auth"];
38
- export?: NonNullable<ConfigV2["gateway"]>["export"];
39
- policies: NonNullable<NonNullable<ConfigV2["gateway"]>["policies"]>;
40
- };
41
-
42
- export type ResolvedIdentity = {
43
- enabled: boolean;
44
- provider: "apitogo" | "oidc";
45
- native: boolean;
46
- connections: NonNullable<NonNullable<ConfigV2["identity"]>["connections"]>;
47
- clients: NonNullable<NonNullable<ConfigV2["identity"]>["clients"]>;
48
- issuer?: string;
49
- clientId?: string;
50
- providerOptions: NonNullable<
51
- NonNullable<ConfigV2["identity"]>["providerOptions"]
52
- >;
53
- };
54
-
55
- export type ResolvedPlans = {
56
- enabled: boolean;
57
- /** Absolute path of the plan catalog. */
58
- file: string;
59
- billing: {
60
- provider: "stripe" | "none";
61
- apiKey?: string;
62
- webhookSecret?: string;
63
- successUrl?: string;
64
- cancelUrl?: string;
65
- };
66
- };
67
-
68
- export type ResolvedConfigV2 = {
69
- configPath: string;
70
- configDir: string;
71
- layout: Layout;
72
- /** `<root>` of the layout: where `portal/`, `plans.json` and `apis/` live by default. */
73
- root: string;
74
- /** Default folder for OpenAPI files created by `init`. */
75
- apisDir: string;
76
- /** Generated and observed state (`state.json`, `generated/`, `build/`), ignored by git on its own. */
77
- state: StatePaths;
78
- project: { id?: string; name: string };
79
- portal?: ResolvedPortal;
80
- gateway?: ResolvedGateway;
81
- identity?: ResolvedIdentity;
82
- plans?: ResolvedPlans;
83
- };
84
-
85
- /**
86
- * Applies the documented defaults and makes every path absolute. Defaults for `portal.dir`, `plans.file`
87
- * and the spec folder derive from `<root>`, so a standalone config can omit them. A section that is present
88
- * without `enabled` is enabled.
89
- */
90
- export function resolveConfigV2(
91
- config: ConfigV2,
92
- configPath: string,
93
- ): ResolvedConfigV2 {
94
- const absoluteConfigPath = path.resolve(configPath);
95
- const configDir = path.dirname(absoluteConfigPath);
96
- const layout = config.layout ?? "standalone";
97
- const root = layoutRoot(configDir, layout);
98
- const projectName = config.project?.name ?? path.basename(configDir);
99
-
100
- const resolved: ResolvedConfigV2 = {
101
- configPath: absoluteConfigPath,
102
- configDir,
103
- layout,
104
- root,
105
- apisDir: path.join(root, "apis"),
106
- state: statePathsFor(configDir, layout),
107
- project: { id: config.project?.id, name: projectName },
108
- };
109
-
110
- const { portal, gateway, identity, plans } = config;
111
-
112
- if (portal) {
113
- const dir = portal.dir
114
- ? path.resolve(configDir, portal.dir)
115
- : path.join(root, "portal");
116
- const provider = portal.docs?.provider ?? "apitogo";
117
- resolved.portal = {
118
- enabled: portal.enabled ?? true,
119
- dir,
120
- name: portal.name ?? projectName,
121
- docs: {
122
- enabled: portal.docs?.enabled ?? true,
123
- provider,
124
- content:
125
- provider === "external"
126
- ? []
127
- : portal.docs?.content
128
- ? portal.docs.content.map((glob) => fromConfig(configDir, glob))
129
- : [path.join(dir, "pages", "**", "*.{md,mdx}")],
130
- url: portal.docs?.url,
131
- },
132
- console: { enabled: portal.console?.enabled ?? true },
133
- theme: portal.theme
134
- ? path.resolve(configDir, portal.theme)
135
- : path.join(dir, "theme.json"),
136
- requireAuth: portal.requireAuth ?? false,
137
- search: provider === "external" ? "none" : (portal.search ?? "pagefind"),
138
- };
139
- }
140
-
141
- if (gateway) {
142
- resolved.gateway = {
143
- enabled: gateway.enabled ?? true,
144
- provider: gateway.provider ?? "apitogo",
145
- origin: {
146
- ...gateway.origin,
147
- source: gateway.origin.source
148
- ? path.resolve(configDir, gateway.origin.source)
149
- : undefined,
150
- },
151
- specs: gateway.specs.map((spec) => path.resolve(configDir, spec)),
152
- auth: gateway.auth,
153
- export: gateway.export,
154
- policies: gateway.policies ?? {},
155
- };
156
- }
157
-
158
- if (identity) {
159
- resolved.identity = {
160
- enabled: identity.enabled ?? true,
161
- provider: identity.provider ?? "apitogo",
162
- native: identity.native ?? true,
163
- connections: identity.connections ?? [],
164
- clients: identity.clients ?? [],
165
- issuer: identity.issuer,
166
- clientId: identity.clientId,
167
- providerOptions: identity.providerOptions ?? {},
168
- };
169
- }
170
-
171
- if (plans) {
172
- resolved.plans = {
173
- enabled: plans.enabled ?? true,
174
- file: plans.file
175
- ? path.resolve(configDir, plans.file)
176
- : path.join(root, "plans.json"),
177
- billing: {
178
- provider: plans.billing?.provider ?? "stripe",
179
- apiKey: plans.billing?.apiKey,
180
- webhookSecret: plans.billing?.webhookSecret,
181
- successUrl: plans.billing?.successUrl,
182
- cancelUrl: plans.billing?.cancelUrl,
183
- },
184
- };
185
- }
186
-
187
- return resolved;
188
- }
1
+ import path from "node:path";
2
+ import { layoutRoot, type StatePaths, statePathsFor } from "./paths.js";
3
+ import type { ConfigV2, Layout } from "./schema.js";
4
+
5
+ export { layoutRoot };
6
+
7
+ /** A path relative to the config file, made absolute. A leading `!` (negated glob) is preserved. */
8
+ function fromConfig(configDir: string, value: string): string {
9
+ return value.startsWith("!")
10
+ ? `!${path.resolve(configDir, value.slice(1))}`
11
+ : path.resolve(configDir, value);
12
+ }
13
+
14
+ export type ResolvedPortal = {
15
+ enabled: boolean;
16
+ dir: string;
17
+ name: string;
18
+ docs: {
19
+ enabled: boolean;
20
+ provider: "apitogo" | "external";
21
+ /** Absolute globs. Empty for an external docs provider. */
22
+ content: string[];
23
+ url?: string;
24
+ };
25
+ console: { enabled: boolean };
26
+ theme: string;
27
+ requireAuth: boolean | { exclude: string[] };
28
+ search: "pagefind" | "inkeep" | "none";
29
+ };
30
+
31
+ export type ResolvedGateway = {
32
+ enabled: boolean;
33
+ provider: string;
34
+ origin: NonNullable<ConfigV2["gateway"]>["origin"];
35
+ /** Absolute paths. */
36
+ specs: string[];
37
+ auth?: NonNullable<ConfigV2["gateway"]>["auth"];
38
+ export?: NonNullable<ConfigV2["gateway"]>["export"];
39
+ policies: NonNullable<NonNullable<ConfigV2["gateway"]>["policies"]>;
40
+ };
41
+
42
+ export type ResolvedIdentity = {
43
+ enabled: boolean;
44
+ provider: "apitogo" | "oidc";
45
+ native: boolean;
46
+ connections: NonNullable<NonNullable<ConfigV2["identity"]>["connections"]>;
47
+ clients: NonNullable<NonNullable<ConfigV2["identity"]>["clients"]>;
48
+ issuer?: string;
49
+ clientId?: string;
50
+ providerOptions: NonNullable<
51
+ NonNullable<ConfigV2["identity"]>["providerOptions"]
52
+ >;
53
+ };
54
+
55
+ export type ResolvedPlans = {
56
+ enabled: boolean;
57
+ /** Absolute path of the plan catalog. */
58
+ file: string;
59
+ billing: {
60
+ provider: "stripe" | "none";
61
+ apiKey?: string;
62
+ webhookSecret?: string;
63
+ successUrl?: string;
64
+ cancelUrl?: string;
65
+ };
66
+ };
67
+
68
+ export type ResolvedConfigV2 = {
69
+ configPath: string;
70
+ configDir: string;
71
+ layout: Layout;
72
+ /** `<root>` of the layout: where `portal/`, `plans.json` and `apis/` live by default. */
73
+ root: string;
74
+ /** Default folder for OpenAPI files created by `init`. */
75
+ apisDir: string;
76
+ /** Generated and observed state (`state.json`, `generated/`, `build/`), ignored by git on its own. */
77
+ state: StatePaths;
78
+ project: { id?: string; name: string };
79
+ portal?: ResolvedPortal;
80
+ gateway?: ResolvedGateway;
81
+ identity?: ResolvedIdentity;
82
+ plans?: ResolvedPlans;
83
+ };
84
+
85
+ /**
86
+ * Applies the documented defaults and makes every path absolute. Defaults for `portal.dir`, `plans.file`
87
+ * and the spec folder derive from `<root>`, so a standalone config can omit them. A section that is present
88
+ * without `enabled` is enabled.
89
+ */
90
+ export function resolveConfigV2(
91
+ config: ConfigV2,
92
+ configPath: string,
93
+ ): ResolvedConfigV2 {
94
+ const absoluteConfigPath = path.resolve(configPath);
95
+ const configDir = path.dirname(absoluteConfigPath);
96
+ const layout = config.layout ?? "standalone";
97
+ const root = layoutRoot(configDir, layout);
98
+ const projectName = config.project?.name ?? path.basename(configDir);
99
+
100
+ const resolved: ResolvedConfigV2 = {
101
+ configPath: absoluteConfigPath,
102
+ configDir,
103
+ layout,
104
+ root,
105
+ apisDir: path.join(root, "apis"),
106
+ state: statePathsFor(configDir, layout),
107
+ project: { id: config.project?.id, name: projectName },
108
+ };
109
+
110
+ const { portal, gateway, identity, plans } = config;
111
+
112
+ if (portal) {
113
+ const dir = portal.dir
114
+ ? path.resolve(configDir, portal.dir)
115
+ : path.join(root, "portal");
116
+ const provider = portal.docs?.provider ?? "apitogo";
117
+ resolved.portal = {
118
+ enabled: portal.enabled ?? true,
119
+ dir,
120
+ name: portal.name ?? projectName,
121
+ docs: {
122
+ enabled: portal.docs?.enabled ?? true,
123
+ provider,
124
+ content:
125
+ provider === "external"
126
+ ? []
127
+ : portal.docs?.content
128
+ ? portal.docs.content.map((glob) => fromConfig(configDir, glob))
129
+ : [path.join(dir, "pages", "**", "*.{md,mdx}")],
130
+ url: portal.docs?.url,
131
+ },
132
+ console: { enabled: portal.console?.enabled ?? true },
133
+ theme: portal.theme
134
+ ? path.resolve(configDir, portal.theme)
135
+ : path.join(dir, "theme.json"),
136
+ requireAuth: portal.requireAuth ?? false,
137
+ search: provider === "external" ? "none" : (portal.search ?? "pagefind"),
138
+ };
139
+ }
140
+
141
+ if (gateway) {
142
+ resolved.gateway = {
143
+ enabled: gateway.enabled ?? true,
144
+ provider: gateway.provider ?? "apitogo",
145
+ origin: {
146
+ ...gateway.origin,
147
+ source: gateway.origin.source
148
+ ? path.resolve(configDir, gateway.origin.source)
149
+ : undefined,
150
+ },
151
+ specs: gateway.specs.map((spec) => path.resolve(configDir, spec)),
152
+ auth: gateway.auth,
153
+ export: gateway.export,
154
+ policies: gateway.policies ?? {},
155
+ };
156
+ }
157
+
158
+ if (identity) {
159
+ resolved.identity = {
160
+ enabled: identity.enabled ?? true,
161
+ provider: identity.provider ?? "apitogo",
162
+ native: identity.native ?? true,
163
+ connections: identity.connections ?? [],
164
+ clients: identity.clients ?? [],
165
+ issuer: identity.issuer,
166
+ clientId: identity.clientId,
167
+ providerOptions: identity.providerOptions ?? {},
168
+ };
169
+ }
170
+
171
+ if (plans) {
172
+ resolved.plans = {
173
+ enabled: plans.enabled ?? true,
174
+ file: plans.file
175
+ ? path.resolve(configDir, plans.file)
176
+ : path.join(root, "plans.json"),
177
+ billing: {
178
+ provider: plans.billing?.provider ?? "stripe",
179
+ apiKey: plans.billing?.apiKey,
180
+ webhookSecret: plans.billing?.webhookSecret,
181
+ successUrl: plans.billing?.successUrl,
182
+ cancelUrl: plans.billing?.cancelUrl,
183
+ },
184
+ };
185
+ }
186
+
187
+ // With plans, a customer calls the gateway with the API key its plan gave it, not with a login token. Without this
188
+ // default the gateway also demanded a token (the identity's `jwt` rule) and a key alone got 401. A config that sets
189
+ // `gateway.policies.access` itself is never changed.
190
+ if (
191
+ resolved.gateway &&
192
+ resolved.plans?.enabled &&
193
+ resolved.gateway.policies.access === undefined
194
+ ) {
195
+ resolved.gateway.policies = {
196
+ ...resolved.gateway.policies,
197
+ access: { jwt: false, apiKey: true },
198
+ };
199
+ }
200
+
201
+ return resolved;
202
+ }
@@ -142,6 +142,9 @@ export function publishedManifest(
142
142
 
143
143
  const DEFAULT_SCOPES = ["openid", "profile", "email"];
144
144
 
145
+ /** Where the user panel's placeholder plans page lives, so that `/account/plans` stays with the billing plugin. */
146
+ const USER_PANEL_PLANS_PLACEHOLDER_PATH = "your-plan";
147
+
145
148
  /**
146
149
  * Pure: decides which plugins and modules the portal registers. A service contributes only when it has a manifest
147
150
  * entry, is ready and declares the capability that the piece needs.
@@ -273,9 +276,16 @@ export function buildSiteConfig(
273
276
  modules: {
274
277
  docs: { enabled: plan.docs },
275
278
  // The plans pages (pricing, choosing a plan) exist only when billing is on, and the plans service is what makes it so.
279
+ // Switching billing on also makes the user panel register its own placeholder plans page, at `/account/<path>`.
280
+ // Its default path is `plans`, which is where the billing plugin puts the real page (subscribe, switch, cancel),
281
+ // and the placeholder won: the "Change plan" link opened an empty page. So the placeholder gets its own path.
276
282
  userPanel: {
277
283
  enabled: plan.userPanel,
278
- ...(plan.plansPlugin ? { plans: { enabled: true } } : {}),
284
+ ...(plan.plansPlugin
285
+ ? {
286
+ plans: { enabled: true, path: USER_PANEL_PLANS_PLACEHOLDER_PATH },
287
+ }
288
+ : {}),
279
289
  },
280
290
  },
281
291
  ...(plan.docs