@lukoweb/apitogo 0.2.47 → 0.2.49

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/docs/cli/shape.md CHANGED
@@ -50,9 +50,10 @@ continues until you type `done`:
50
50
 
51
51
  Fonts and colors are **not** prompted. See [Colors & theme](../customization/colors-theme.mdx).
52
52
 
53
- **Paid plans:** a plan with a price needs `plans.billing.provider: "stripe"`, and **Starter** or
54
- **Professional** platform hosting on deploy. **Free** cannot publish paid gateway plans or custom
55
- domains.
53
+ **Paid plans:** payments go through Stripe. Once a plan has a price, `npx apitogo deploy` asks for
54
+ your Stripe secret key (if the project has none yet) and stores it as a project secret. Free and
55
+ contact-us plans need no key. Paid plans also need **Starter** or **Professional** platform hosting;
56
+ **Free** cannot publish paid gateway plans or custom domains.
56
57
 
57
58
  Full reference: [Make config (CLI)](../configuration/make-config.mdx) ·
58
59
  [Gateway plans](../configuration/plans.md)
@@ -37,7 +37,7 @@ below use the installed CLI through `npx apitogo`.
37
37
  ```bash
38
38
  npx apitogo add identity
39
39
  npx apitogo add gateway --origin proxy --url https://httpbin.org --auth identity --create-spec
40
- npx apitogo add plans --template free --billing none
40
+ npx apitogo add plans --template free
41
41
  ```
42
42
 
43
43
  | Service | What it gives you |
@@ -117,32 +117,21 @@ Professional plan with APItoGo, and your own Stripe account. Your customers pay
117
117
  opens the platform's checkout for the hosting fee. A project that already exists on the Free plan
118
118
  cannot be moved: create a new one. See
119
119
  [Selling paid plans needs Starter or Professional](./commands.md#selling-paid-plans-needs-starter-or-professional).
120
- 2. In your Stripe account, copy the secret key (it starts with `sk_test_` while you test). Store it
121
- as a project secret. The command asks for the value, so it stays out of the config:
122
-
123
- ```bash
124
- npx apitogo secrets set STRIPE_KEY
125
- ```
126
-
127
- 3. Turn billing on in `apitogo.config.json`:
128
-
129
- ```json
130
- "plans": {
131
- "billing": { "provider": "stripe", "apiKey": "secret:STRIPE_KEY" }
132
- }
133
- ```
134
-
135
- 4. Give a plan a price in `plans.json`: `"priceAmount": 49`, `"priceCurrency": "usd"` and
136
- `"billingPeriod": "month"`. Its limits cannot be higher than your platform plan allows. A plan
137
- with a price is refused while billing is off, so nobody is offered a plan they cannot pay for.
138
- 5. Deploy: `npx apitogo deploy`. The output says whether Stripe is in test or live mode and prints
139
- your portal's webhook address.
140
- 6. In Stripe, add that address as a webhook endpoint with the events `checkout.session.completed`,
120
+ 2. Give a plan a price in `plans.json`: `"priceAmount": 49`, `"priceCurrency": "usd"` and
121
+ `"billingInterval": "month"`. Its limits cannot be higher than your platform plan allows.
122
+ Payments go through Stripe; nothing else in the config changes.
123
+ 3. Deploy: `npx apitogo deploy`. Because a plan now has a price, deploy asks for your Stripe secret
124
+ key (it starts with `sk_test_` while you test) and stores it as the project secret
125
+ `STRIPE_API_KEY`, so it stays out of your files. You can also store it beforehand with
126
+ `npx apitogo secrets set STRIPE_API_KEY`. The output says whether Stripe is in test or live mode
127
+ and prints your portal's webhook address.
128
+ 4. In Stripe, add that address as a webhook endpoint with the events `checkout.session.completed`,
141
129
  `customer.subscription.updated`, `customer.subscription.deleted` and `invoice.payment_failed`.
142
130
  Copy the signing secret (it starts with `whsec_`), store it with
143
- `npx apitogo secrets set STRIPE_WEBHOOK`, add `"webhookSecret": "secret:STRIPE_WEBHOOK"` next to
144
- `apiKey`, and deploy again. Until then a payment does not activate the plan, because the portal
145
- cannot confirm it came from Stripe.
131
+ `npx apitogo secrets set STRIPE_WEBHOOK`, add
132
+ `"plans": { "billing": { "webhookSecret": "secret:STRIPE_WEBHOOK" } }` to `apitogo.config.json`,
133
+ and deploy again. Until then a payment does not activate the plan, because the portal cannot
134
+ confirm it came from Stripe.
146
135
 
147
136
  While you use a test key, pay with [Stripe's test cards](https://docs.stripe.com/testing). No real
148
137
  money moves. Never type a live key until you have tested the whole path.
@@ -44,8 +44,9 @@ On a TTY, bare `npx apitogo config` opens the wizard. Each answer is applied at
44
44
  - **`plan`**: title, fixed or custom price, rate limit per minute, quota per 30 days; added to
45
45
  `plans.json`
46
46
 
47
- If you add a **paid** plan while `plans.billing.provider` is not `stripe`, the CLI warns that deploy
48
- will refuse it. Paid plans also need Starter or Professional platform hosting.
47
+ Payments go through Stripe. When you add a **paid** plan, `npx apitogo deploy` asks for your Stripe
48
+ secret key the first time (if the project has none yet). Free and contact-us plans need no key. Paid
49
+ plans also need Starter or Professional platform hosting.
49
50
 
50
51
  ## Options
51
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lukoweb/apitogo",
3
- "version": "0.2.47",
3
+ "version": "0.2.49",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -1,6 +1,6 @@
1
- {
2
- "version": 2,
3
- "layout": "standalone",
4
- "project": { "name": "my-docs" },
5
- "portal": { "name": "My Docs" }
6
- }
1
+ {
2
+ "version": 2,
3
+ "layout": "standalone",
4
+ "project": { "name": "my-docs" },
5
+ "portal": { "name": "My Docs" }
6
+ }
@@ -1,14 +1,14 @@
1
- {
2
- "version": 2,
3
- "layout": "embedded",
4
- "gateway": {
5
- "origin": {
6
- "mode": "managed",
7
- "source": ".",
8
- "runtime": "nodejs",
9
- "runtimeVersion": "NODE_22_LTS",
10
- "exclude": ["apitogo/**", "node_modules/**"]
11
- },
12
- "specs": ["openapi.yaml"]
13
- }
14
- }
1
+ {
2
+ "version": 2,
3
+ "layout": "embedded",
4
+ "gateway": {
5
+ "origin": {
6
+ "mode": "managed",
7
+ "source": ".",
8
+ "runtime": "nodejs",
9
+ "runtimeVersion": "NODE_22_LTS",
10
+ "exclude": ["apitogo/**", "node_modules/**"]
11
+ },
12
+ "specs": ["openapi.yaml"]
13
+ }
14
+ }
@@ -1,15 +1,15 @@
1
- {
2
- "version": 2,
3
- "layout": "standalone",
4
- "gateway": {
5
- "origin": { "mode": "proxy", "url": "https://api.acme.com" },
6
- "specs": ["apis/openapi.yaml"],
7
- "auth": {
8
- "mode": "oidc",
9
- "metadataUrl": "https://idp.acme.com/.well-known/openid-configuration",
10
- "issuers": ["https://idp.acme.com/"],
11
- "audiences": ["api://acme-orders"]
12
- },
13
- "policies": { "rateLimit": { "calls": 60, "periodSeconds": 60 } }
14
- }
15
- }
1
+ {
2
+ "version": 2,
3
+ "layout": "standalone",
4
+ "gateway": {
5
+ "origin": { "mode": "proxy", "url": "https://api.acme.com" },
6
+ "specs": ["apis/openapi.yaml"],
7
+ "auth": {
8
+ "mode": "oidc",
9
+ "metadataUrl": "https://idp.acme.com/.well-known/openid-configuration",
10
+ "issuers": ["https://idp.acme.com/"],
11
+ "audiences": ["api://acme-orders"]
12
+ },
13
+ "policies": { "rateLimit": { "calls": 60, "periodSeconds": 60 } }
14
+ }
15
+ }
@@ -1,28 +1,28 @@
1
- {
2
- "$schema": "https://apitogo.com/schema/config-v2.json",
3
- "version": 2,
4
- "layout": "embedded",
5
- "project": { "id": "prj_123", "name": "acme-orders" },
6
- "gateway": {
7
- "origin": {
8
- "mode": "managed",
9
- "source": "server",
10
- "runtime": "nodejs",
11
- "runtimeVersion": "NODE_22_LTS"
12
- },
13
- "specs": ["server/docs/swagger.yaml"],
14
- "auth": { "mode": "identity" }
15
- },
16
- "identity": { "native": true },
17
- "plans": {
18
- "billing": {
19
- "provider": "stripe",
20
- "apiKey": "secret:STRIPE_API_KEY",
21
- "webhookSecret": "secret:STRIPE_WEBHOOK_SECRET"
22
- }
23
- },
24
- "portal": {
25
- "name": "Acme Orders",
26
- "docs": { "content": ["docs/**/*.{md,mdx}"] }
27
- }
28
- }
1
+ {
2
+ "$schema": "https://apitogo.com/schema/config-v2.json",
3
+ "version": 2,
4
+ "layout": "embedded",
5
+ "project": { "id": "prj_123", "name": "acme-orders" },
6
+ "gateway": {
7
+ "origin": {
8
+ "mode": "managed",
9
+ "source": "server",
10
+ "runtime": "nodejs",
11
+ "runtimeVersion": "NODE_22_LTS"
12
+ },
13
+ "specs": ["server/docs/swagger.yaml"],
14
+ "auth": { "mode": "identity" }
15
+ },
16
+ "identity": { "native": true },
17
+ "plans": {
18
+ "billing": {
19
+ "provider": "stripe",
20
+ "apiKey": "secret:STRIPE_API_KEY",
21
+ "webhookSecret": "secret:STRIPE_WEBHOOK_SECRET"
22
+ }
23
+ },
24
+ "portal": {
25
+ "name": "Acme Orders",
26
+ "docs": { "content": ["docs/**/*.{md,mdx}"] }
27
+ }
28
+ }
@@ -0,0 +1,59 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import type { ResolvedConfigV2 } from "./resolve.js";
3
+
4
+ /**
5
+ * Billing (decision 21, narrowed): Stripe is the only payment provider. Billing is on exactly when the plan catalog has a
6
+ * plan with a price, so a project with free and contact-us plans needs no Stripe key and is never asked for one.
7
+ */
8
+
9
+ /** The project secret that holds the Stripe key when `plans.billing.apiKey` names none. */
10
+ export const DEFAULT_STRIPE_KEY_SECRET = "STRIPE_API_KEY";
11
+
12
+ type PriceFields = { pricingModel?: unknown; priceAmount?: unknown };
13
+
14
+ /** A plan someone pays for: a fixed price above 0. A contact-us (custom) plan is not paid here. */
15
+ export function isPaidPlan(plan: PriceFields): boolean {
16
+ return (
17
+ plan.pricingModel !== "custom" &&
18
+ typeof plan.priceAmount === "number" &&
19
+ plan.priceAmount > 0
20
+ );
21
+ }
22
+
23
+ /** The skus of the paid plans in the plans file. A missing or unreadable file has none; the plans rules report it. */
24
+ export async function paidPlanSkus(
25
+ resolved: ResolvedConfigV2,
26
+ ): Promise<string[]> {
27
+ const file = resolved.plans?.file;
28
+ if (!file) return [];
29
+ try {
30
+ const raw = JSON.parse(await readFile(file, "utf8")) as {
31
+ plans?: unknown;
32
+ };
33
+ if (!Array.isArray(raw.plans)) return [];
34
+ return raw.plans
35
+ .filter(
36
+ (plan): plan is PriceFields & { sku?: unknown } =>
37
+ typeof plan === "object" && plan !== null && isPaidPlan(plan),
38
+ )
39
+ .map((plan) => String(plan.sku ?? "?"));
40
+ } catch {
41
+ return [];
42
+ }
43
+ }
44
+
45
+ /** True when the plans service takes payments: Stripe is not switched off and a plan has a price. */
46
+ export async function billingNeeded(
47
+ resolved: ResolvedConfigV2,
48
+ ): Promise<boolean> {
49
+ const plans = resolved.plans;
50
+ if (!plans?.enabled || plans.billing.provider !== "stripe") return false;
51
+ return (await paidPlanSkus(resolved)).length > 0;
52
+ }
53
+
54
+ /** The name of the secret that holds the Stripe key (`secret:NAME` in `plans.billing.apiKey`, default `STRIPE_API_KEY`). */
55
+ export function stripeKeySecretName(resolved: ResolvedConfigV2): string {
56
+ const ref = resolved.plans?.billing.apiKey;
57
+ const match = ref ? /^secret:(.+)$/.exec(ref) : null;
58
+ return match?.[1] ?? DEFAULT_STRIPE_KEY_SECRET;
59
+ }
@@ -1,72 +1,72 @@
1
- import type { InputNavigationItem } from "../validators/InputNavigationSchema.js";
2
-
3
- /** Label of the one header tab that holds the sidebar made from the docs pages. */
4
- export const DOCS_NAVIGATION_LABEL = "Documentation";
5
-
6
- type Branch = { pages: Set<string>; folders: Map<string, Branch> };
7
-
8
- const emptyBranch = (): Branch => ({ pages: new Set(), folders: new Map() });
9
-
10
- /** "getting-started" becomes "Getting started". */
11
- export function folderLabel(name: string): string {
12
- const words = name.replace(/[-_]+/g, " ").trim();
13
- return words.charAt(0).toUpperCase() + words.slice(1);
14
- }
15
-
16
- const byName = (a: string, b: string) => a.localeCompare(b);
17
-
18
- const toItems = (branch: Branch, prefix: string): InputNavigationItem[] => {
19
- const pages = [...branch.pages].sort((a, b) =>
20
- a === "index" ? -1 : b === "index" ? 1 : byName(a, b),
21
- );
22
- const folders = [...branch.folders.keys()].sort(byName);
23
- return [
24
- // The index page of a folder is the folder's own address, so the sidebar also shows on `/`.
25
- ...pages.map(
26
- (page): InputNavigationItem =>
27
- page === "index"
28
- ? {
29
- type: "doc",
30
- file: `${prefix}${page}`,
31
- path: prefix === "" ? "/" : `/${prefix.slice(0, -1)}`,
32
- }
33
- : `${prefix}${page}`,
34
- ),
35
- ...folders.map(
36
- (folder): InputNavigationItem => ({
37
- type: "category",
38
- label: folderLabel(folder),
39
- collapsible: true,
40
- collapsed: false,
41
- items: toItems(
42
- branch.folders.get(folder) ?? emptyBranch(),
43
- `${prefix}${folder}/`,
44
- ),
45
- }),
46
- ),
47
- ];
48
- };
49
-
50
- /**
51
- * The sidebar for hosted docs, made from the pages themselves: the index page first, then the other pages by
52
- * name, then one section per folder. `routes` are the docs plugin's route paths (`/overview`, `/guides/style`).
53
- * Without pages there is nothing to navigate, so the result is empty.
54
- */
55
- export function docsNavigation(routes: string[]): InputNavigationItem[] {
56
- const root = emptyBranch();
57
- for (const route of routes) {
58
- const parts = route.split("/").filter(Boolean);
59
- const page = parts.pop();
60
- if (!page) continue;
61
- const branch = parts.reduce((current, folder) => {
62
- const next = current.folders.get(folder) ?? emptyBranch();
63
- current.folders.set(folder, next);
64
- return next;
65
- }, root);
66
- branch.pages.add(page);
67
- }
68
- const items = toItems(root, "");
69
- return items.length === 0
70
- ? []
71
- : [{ type: "category", label: DOCS_NAVIGATION_LABEL, items }];
72
- }
1
+ import type { InputNavigationItem } from "../validators/InputNavigationSchema.js";
2
+
3
+ /** Label of the one header tab that holds the sidebar made from the docs pages. */
4
+ export const DOCS_NAVIGATION_LABEL = "Documentation";
5
+
6
+ type Branch = { pages: Set<string>; folders: Map<string, Branch> };
7
+
8
+ const emptyBranch = (): Branch => ({ pages: new Set(), folders: new Map() });
9
+
10
+ /** "getting-started" becomes "Getting started". */
11
+ export function folderLabel(name: string): string {
12
+ const words = name.replace(/[-_]+/g, " ").trim();
13
+ return words.charAt(0).toUpperCase() + words.slice(1);
14
+ }
15
+
16
+ const byName = (a: string, b: string) => a.localeCompare(b);
17
+
18
+ const toItems = (branch: Branch, prefix: string): InputNavigationItem[] => {
19
+ const pages = [...branch.pages].sort((a, b) =>
20
+ a === "index" ? -1 : b === "index" ? 1 : byName(a, b),
21
+ );
22
+ const folders = [...branch.folders.keys()].sort(byName);
23
+ return [
24
+ // The index page of a folder is the folder's own address, so the sidebar also shows on `/`.
25
+ ...pages.map(
26
+ (page): InputNavigationItem =>
27
+ page === "index"
28
+ ? {
29
+ type: "doc",
30
+ file: `${prefix}${page}`,
31
+ path: prefix === "" ? "/" : `/${prefix.slice(0, -1)}`,
32
+ }
33
+ : `${prefix}${page}`,
34
+ ),
35
+ ...folders.map(
36
+ (folder): InputNavigationItem => ({
37
+ type: "category",
38
+ label: folderLabel(folder),
39
+ collapsible: true,
40
+ collapsed: false,
41
+ items: toItems(
42
+ branch.folders.get(folder) ?? emptyBranch(),
43
+ `${prefix}${folder}/`,
44
+ ),
45
+ }),
46
+ ),
47
+ ];
48
+ };
49
+
50
+ /**
51
+ * The sidebar for hosted docs, made from the pages themselves: the index page first, then the other pages by
52
+ * name, then one section per folder. `routes` are the docs plugin's route paths (`/overview`, `/guides/style`).
53
+ * Without pages there is nothing to navigate, so the result is empty.
54
+ */
55
+ export function docsNavigation(routes: string[]): InputNavigationItem[] {
56
+ const root = emptyBranch();
57
+ for (const route of routes) {
58
+ const parts = route.split("/").filter(Boolean);
59
+ const page = parts.pop();
60
+ if (!page) continue;
61
+ const branch = parts.reduce((current, folder) => {
62
+ const next = current.folders.get(folder) ?? emptyBranch();
63
+ current.folders.set(folder, next);
64
+ return next;
65
+ }, root);
66
+ branch.pages.add(page);
67
+ }
68
+ const items = toItems(root, "");
69
+ return items.length === 0
70
+ ? []
71
+ : [{ type: "category", label: DOCS_NAVIGATION_LABEL, items }];
72
+ }
@@ -1,42 +1,42 @@
1
- import { ApitogoError } from "../../lib/util/invariant.js";
2
-
3
- /**
4
- * One problem in a config file. `code` is stable and safe to match on, `path` is the dotted config path
5
- * (for example `gateway.origin.mode`), `hint` says how to fix it.
6
- */
7
- export type ConfigIssue = {
8
- code: string;
9
- path: string;
10
- message: string;
11
- hint?: string;
12
- };
13
-
14
- export function formatConfigIssue(issue: ConfigIssue): string {
15
- const hint = issue.hint ? ` ${issue.hint}` : "";
16
- const rest = issue.message.slice(issue.path.length);
17
- // Several documented messages already begin with their own path; do not print it twice.
18
- const startsWithPath =
19
- issue.path !== "" &&
20
- issue.message.startsWith(issue.path) &&
21
- (rest === "" || /^[\s:]/.test(rest));
22
- const text = startsWithPath
23
- ? issue.message
24
- : `${issue.path === "" ? "(root)" : issue.path}: ${issue.message}`;
25
- return `${text}${hint} [${issue.code}]`;
26
- }
27
-
28
- /** Thrown when an `apitogo.config.json` cannot be loaded. Carries every problem found, not just the first. */
29
- export class ConfigError extends ApitogoError {
30
- public readonly issues: readonly ConfigIssue[];
31
-
32
- constructor(file: string, issues: readonly ConfigIssue[]) {
33
- super(
34
- `${file} has ${issues.length} problem${issues.length === 1 ? "" : "s"}:\n${issues
35
- .map((issue) => ` - ${formatConfigIssue(issue)}`)
36
- .join("\n")}`,
37
- { title: "Invalid apitogo config" },
38
- );
39
- this.name = "ConfigError";
40
- this.issues = issues;
41
- }
42
- }
1
+ import { ApitogoError } from "../../lib/util/invariant.js";
2
+
3
+ /**
4
+ * One problem in a config file. `code` is stable and safe to match on, `path` is the dotted config path
5
+ * (for example `gateway.origin.mode`), `hint` says how to fix it.
6
+ */
7
+ export type ConfigIssue = {
8
+ code: string;
9
+ path: string;
10
+ message: string;
11
+ hint?: string;
12
+ };
13
+
14
+ export function formatConfigIssue(issue: ConfigIssue): string {
15
+ const hint = issue.hint ? ` ${issue.hint}` : "";
16
+ const rest = issue.message.slice(issue.path.length);
17
+ // Several documented messages already begin with their own path; do not print it twice.
18
+ const startsWithPath =
19
+ issue.path !== "" &&
20
+ issue.message.startsWith(issue.path) &&
21
+ (rest === "" || /^[\s:]/.test(rest));
22
+ const text = startsWithPath
23
+ ? issue.message
24
+ : `${issue.path === "" ? "(root)" : issue.path}: ${issue.message}`;
25
+ return `${text}${hint} [${issue.code}]`;
26
+ }
27
+
28
+ /** Thrown when an `apitogo.config.json` cannot be loaded. Carries every problem found, not just the first. */
29
+ export class ConfigError extends ApitogoError {
30
+ public readonly issues: readonly ConfigIssue[];
31
+
32
+ constructor(file: string, issues: readonly ConfigIssue[]) {
33
+ super(
34
+ `${file} has ${issues.length} problem${issues.length === 1 ? "" : "s"}:\n${issues
35
+ .map((issue) => ` - ${formatConfigIssue(issue)}`)
36
+ .join("\n")}`,
37
+ { title: "Invalid apitogo config" },
38
+ );
39
+ this.name = "ConfigError";
40
+ this.issues = issues;
41
+ }
42
+ }