@lukoweb/apitogo 0.2.25 → 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 +36 -3
- package/docs/cli/walkthrough.md +133 -0
- package/docs/guides/customer-account.md +71 -0
- package/package.json +1 -1
- package/src/config/v2/resolve.ts +202 -188
- package/src/config/v2/wrapper.ts +15 -2
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 ? {
|
|
8038
|
+
...plan2.plansPlugin ? {
|
|
8039
|
+
plans: { enabled: true, path: USER_PANEL_PLANS_PLACEHOLDER_PATH }
|
|
8040
|
+
} : {}
|
|
8029
8041
|
}
|
|
8030
8042
|
},
|
|
8031
8043
|
...plan2.docs ? {
|
|
@@ -8060,7 +8072,8 @@ function authenticationFor(plan2) {
|
|
|
8060
8072
|
// Accounts are made at the identity (its own sign-up page), never at the plans backend.
|
|
8061
8073
|
native: { enabled: false }
|
|
8062
8074
|
};
|
|
8063
|
-
|
|
8075
|
+
const { audience: _audience, ...signIn } = plan2.auth ?? {};
|
|
8076
|
+
return { type: "openid", ...signIn };
|
|
8064
8077
|
}
|
|
8065
8078
|
var PREVIEW_BANNER_MESSAGE = "Preview mode: sign-in and plans use a stand-in manifest. Run `apitogo deploy` for the real services.";
|
|
8066
8079
|
function renderWrapper(plan2) {
|
|
@@ -9967,7 +9980,7 @@ import {
|
|
|
9967
9980
|
// package.json
|
|
9968
9981
|
var package_default = {
|
|
9969
9982
|
name: "@lukoweb/apitogo",
|
|
9970
|
-
version: "0.2.
|
|
9983
|
+
version: "0.2.29",
|
|
9971
9984
|
type: "module",
|
|
9972
9985
|
sideEffects: [
|
|
9973
9986
|
"**/*.css",
|
|
@@ -28943,6 +28956,26 @@ async function deployV2(options, deps) {
|
|
|
28943
28956
|
}
|
|
28944
28957
|
results.push(await deployPortal(options, deps, projectId));
|
|
28945
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
|
+
}
|
|
28946
28979
|
if (results.length === 0) {
|
|
28947
28980
|
throw new DeployError(
|
|
28948
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
package/src/config/v2/resolve.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/config/v2/wrapper.ts
CHANGED
|
@@ -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
|
|
284
|
+
...(plan.plansPlugin
|
|
285
|
+
? {
|
|
286
|
+
plans: { enabled: true, path: USER_PANEL_PLANS_PLACEHOLDER_PATH },
|
|
287
|
+
}
|
|
288
|
+
: {}),
|
|
279
289
|
},
|
|
280
290
|
},
|
|
281
291
|
...(plan.docs
|
|
@@ -321,7 +331,10 @@ function authenticationFor(plan: PortalPlan): Record<string, unknown> {
|
|
|
321
331
|
// Accounts are made at the identity (its own sign-up page), never at the plans backend.
|
|
322
332
|
native: { enabled: false },
|
|
323
333
|
};
|
|
324
|
-
|
|
334
|
+
// The audience is for the gateway and the token, not for the sign-in request: the portal would send it as an extra
|
|
335
|
+
// request parameter and the identity refuses a sign-in request that carries one.
|
|
336
|
+
const { audience: _audience, ...signIn } = plan.auth ?? {};
|
|
337
|
+
return { type: "openid", ...signIn };
|
|
325
338
|
}
|
|
326
339
|
|
|
327
340
|
/** Shown on every page of a preview run, so a stand-in manifest is never mistaken for a deployed portal. */
|