@lukoweb/apitogo 0.2.36 → 0.2.37

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.
@@ -598,6 +598,15 @@ export declare class PlatformApiClient {
598
598
  getGatewayPlan(projectId: string, planId: string): Promise<GatewayPlanDto>;
599
599
  updateGatewayPlan(projectId: string, planId: string, body: UpdateGatewayPlanRequest): Promise<GatewayPlanDto>;
600
600
  deleteGatewayPlan(projectId: string, planId: string): Promise<void>;
601
+ createTestSignIn(projectId: string, body: {
602
+ email: string;
603
+ returnUrl?: string;
604
+ }): Promise<{
605
+ userId: string;
606
+ created: boolean;
607
+ url: string;
608
+ expiresInSeconds: number;
609
+ }>;
601
610
  getGatewayAuth(projectId: string): Promise<GatewayAuthConfigDto>;
602
611
  updateGatewayAuth(projectId: string, body: UpdateGatewayAuthRequest): Promise<GatewayAuthConfigDto>;
603
612
  getGatewaySettings(projectId: string): Promise<GatewaySettingsDto>;
@@ -1,8 +1,11 @@
1
1
  import * as oauth from "oauth4webapi";
2
2
  import type { NavigateFunction } from "react-router";
3
3
  import type { OpenIDAuthenticationConfig } from "../../../config/config.js";
4
+ import type { ApiIdentity } from "../../core/ZudokuContext.js";
4
5
  import type { AuthActionContext, AuthActionOptions, AuthenticationPlugin, AuthenticationProviderInitializer } from "../authentication.js";
5
6
  import { CoreAuthenticationPlugin } from "../AuthenticationPlugin.js";
7
+ export declare const SIGNED_IN_HINT_KEY = "apitogo-signed-in-hint";
8
+ export declare const SILENT_ATTEMPT_KEY = "apitogo-silent-signin";
6
9
  export interface OpenIdProviderData {
7
10
  type: "openid" | undefined;
8
11
  accessToken: string;
@@ -41,12 +44,14 @@ export declare class OpenIDAuthenticationProvider extends CoreAuthenticationPlug
41
44
  redirectTo?: string;
42
45
  replace?: boolean;
43
46
  }): Promise<void>;
44
- signIn(_: AuthActionContext, { redirectTo, replace }: AuthActionOptions): Promise<void>;
47
+ signIn(_: AuthActionContext, { redirectTo, replace }?: AuthActionOptions): Promise<void>;
45
48
  refreshUserProfile(): Promise<boolean>;
46
49
  private authorize;
47
50
  getAccessToken(): Promise<string>;
48
51
  signRequest: (request: Request) => Promise<Request>;
52
+ getIdentities: () => Promise<ApiIdentity[]>;
49
53
  signOut: (_: AuthActionContext) => Promise<void>;
54
+ initialize(): Promise<void>;
50
55
  onPageLoad: () => Promise<void>;
51
56
  handleCallback: () => Promise<string>;
52
57
  getRoutes(): {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lukoweb/apitogo",
3
- "version": "0.2.36",
3
+ "version": "0.2.37",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -75,6 +75,8 @@ export type HomeRedirectInput = {
75
75
  apiReferencePath?: string;
76
76
  /** The portal has console pages (sign-in, keys, plans). */
77
77
  userPanel: boolean;
78
+ /** The portal has a pricing page, at this path. */
79
+ pricingPath?: string;
78
80
  };
79
81
 
80
82
  /**
@@ -84,6 +86,9 @@ export type HomeRedirectInput = {
84
86
  * - Hosted docs without an index page: the first page, so the team's own docs never need an extra file.
85
87
  * - Hosted docs without any page: the API reference, when there is one.
86
88
  * - External docs and no console: the external docs site, as the portal has nothing else to show.
89
+ * - No hosted docs but a console: the pricing page when there is one, else the account pages, which lead a signed-out
90
+ * visitor to sign in. The console has no page of its own at `/`, so the address `deploy` prints showed "Page not
91
+ * found" (API-73).
87
92
  */
88
93
  export function homeRedirect(
89
94
  input: HomeRedirectInput,
@@ -100,5 +105,8 @@ export function homeRedirect(
100
105
  if (input.externalDocsUrl && !input.userPanel) {
101
106
  return { from: "/", to: input.externalDocsUrl };
102
107
  }
108
+ if (input.userPanel) {
109
+ return { from: "/", to: input.pricingPath ?? "/account" };
110
+ }
103
111
  return undefined;
104
112
  }
@@ -104,6 +104,8 @@ export {
104
104
  planAddService,
105
105
  planRemoveService,
106
106
  SERVICES,
107
+ SERVICE_LABELS,
108
+ serviceLabel,
107
109
  clearProjectId,
108
110
  ServiceEditError,
109
111
  setProjectId,
@@ -1,273 +1,180 @@
1
- import { access, mkdir, stat, writeFile } from "node:fs/promises";
2
- import path from "node:path";
3
- import { ApitogoError } from "../../lib/util/invariant.js";
4
- import { APITOGO_CONFIG_FILENAME } from "../apitogo-config-core.js";
5
- import {
6
- type DocsCandidate,
7
- detectExistingDocs,
8
- detectLayout,
9
- ensureStateDir,
10
- } from "./layout.js";
11
- import { parseConfigV2 } from "./parse.js";
12
- import { layoutRoot, statePathsFor } from "./paths.js";
13
- import type { ConfigV2, Layout } from "./schema.js";
14
-
15
- export class InitError extends ApitogoError {
16
- constructor(message: string, developerHint?: string) {
17
- super(message, { developerHint, title: "Cannot initialise the project" });
18
- this.name = "InitError";
19
- }
20
- }
21
-
22
- export type InitOptions = {
23
- /** Project directory; created if it does not exist. */
24
- dir: string;
25
- /** Overrides layout detection. */
26
- layout?: Layout;
27
- /** Project and portal name; defaults to the directory name. */
28
- name?: string;
29
- /** Existing docs to include, as paths relative to `dir`; `false` skips docs detection. */
30
- docs?: string[] | false;
31
- /** When several docs candidates exist, include all of them instead of asking. */
32
- acceptAllDocs?: boolean;
33
- /** Replace an existing `apitogo.config.json`. It is the only existing file `init` ever replaces. */
34
- force?: boolean;
35
- };
36
-
37
- export type PlannedEntry = {
38
- kind: "dir" | "file";
39
- /** Absolute path. */
40
- path: string;
41
- /** `state` entries are created by {@link ensureStateDir}. */
42
- purpose: "config" | "state" | "portal";
43
- content?: string;
44
- };
45
-
46
- export type InitPlan = {
47
- dir: string;
48
- configPath: string;
49
- layout: Layout;
50
- layoutSource: "detected" | "flag";
51
- layoutReason: string;
52
- /** The config that will be written; already validated against the v2 schema. */
53
- config: ConfigV2;
54
- docsCandidates: DocsCandidate[];
55
- /** Globs written to `portal.docs.content`. */
56
- docsIncluded: string[];
57
- /** Several candidates were found and none was chosen yet: the caller should ask and plan again. */
58
- needsDocsChoice: boolean;
59
- /** Everything `init` will create. */
60
- entries: PlannedEntry[];
61
- /** Files that already exist and stay untouched. */
62
- keep: string[];
63
- /** An existing config will be replaced (only with `force`). */
64
- replacesConfig: boolean;
65
- };
66
-
67
- async function exists(target: string): Promise<boolean> {
68
- try {
69
- await access(target);
70
- return true;
71
- } catch {
72
- return false;
73
- }
74
- }
75
-
76
- export function starterPage(name: string): string {
77
- return `---
78
- title: Welcome
79
- ---
80
-
81
- # ${name}
82
-
83
- This page was created by \`apitogo init\`. Edit it, and add more \`.md\` or \`.mdx\` files next to it: each one becomes a page of your portal.
84
- `;
85
- }
86
-
87
- /** Turns paths the user named into globs, checking that each exists inside the project. */
88
- export async function docsFromPaths(
89
- dir: string,
90
- requested: readonly string[],
91
- ): Promise<string[]> {
92
- const globs: string[] = [];
93
- for (const requestedPath of requested) {
94
- const absolute = path.resolve(dir, requestedPath);
95
- const relative = path.relative(dir, absolute).split(path.sep).join("/");
96
- if (
97
- relative === "" ||
98
- relative.startsWith("..") ||
99
- path.isAbsolute(relative)
100
- ) {
101
- throw new InitError(
102
- `Docs path "${requestedPath}" is outside the project directory.`,
103
- );
104
- }
105
- const info = await stat(absolute).catch(() => undefined);
106
- if (!info)
107
- throw new InitError(
108
- `Docs path "${requestedPath}" does not exist in ${dir}.`,
109
- );
110
- globs.push(info.isDirectory() ? `${relative}/**/*.{md,mdx}` : relative);
111
- }
112
- return globs;
113
- }
114
-
115
- /**
116
- * Decides what `init` would do without touching the disk (Reference, section 1.2). Existing files are never moved,
117
- * renamed or modified; existing docs are referenced in place; the root `.gitignore` is never edited.
118
- */
119
- export async function planInit(options: InitOptions): Promise<InitPlan> {
120
- const dir = path.resolve(options.dir);
121
- const configPath = path.join(dir, APITOGO_CONFIG_FILENAME);
122
-
123
- const replacesConfig = await exists(configPath);
124
- if (replacesConfig && !options.force) {
125
- throw new InitError(
126
- `${configPath} already exists.`,
127
- "Pass --force to replace it. Nothing else in the project is changed.",
128
- );
129
- }
130
-
131
- const detection = options.layout ? undefined : await detectLayout(dir);
132
- const layout = options.layout ?? detection?.layout ?? "standalone";
133
- const layoutReason = options.layout
134
- ? `chosen with --layout ${options.layout}`
135
- : (detection?.reason ?? "");
136
-
137
- let docsCandidates: DocsCandidate[] = [];
138
- let docsIncluded: string[] = [];
139
- let needsDocsChoice = false;
140
- if (Array.isArray(options.docs) && options.docs.length > 0) {
141
- docsIncluded = await docsFromPaths(dir, options.docs);
142
- } else if (options.docs !== false) {
143
- docsCandidates = await detectExistingDocs(dir);
144
- if (
145
- docsCandidates.length === 1 ||
146
- (docsCandidates.length > 1 && options.acceptAllDocs)
147
- ) {
148
- docsIncluded = docsCandidates.map((candidate) => candidate.glob);
149
- } else if (docsCandidates.length > 1) {
150
- needsDocsChoice = true;
151
- }
152
- }
153
-
154
- const name = options.name?.trim() || path.basename(dir);
155
- const config = parseConfigV2({
156
- version: 2,
157
- layout,
158
- project: { name },
159
- portal: {
160
- name,
161
- ...(docsIncluded.length > 0 ? { docs: { content: docsIncluded } } : {}),
162
- },
163
- });
164
-
165
- const root = layoutRoot(dir, layout);
166
- const state = statePathsFor(dir, layout);
167
- const entries: PlannedEntry[] = [
168
- {
169
- kind: "file",
170
- path: configPath,
171
- purpose: "config",
172
- content: `${JSON.stringify(config, null, 2)}\n`,
173
- },
174
- { kind: "file", path: state.gitignore, purpose: "state", content: "*\n" },
175
- { kind: "dir", path: state.generatedDir, purpose: "state" },
176
- { kind: "dir", path: state.buildDir, purpose: "state" },
177
- ];
178
-
179
- const keep: string[] = [];
180
- if (docsIncluded.length === 0) {
181
- // No existing docs to reference: give the portal an empty pages folder with one starter page.
182
- const starter = path.join(root, "portal", "pages", "index.mdx");
183
- if (await exists(starter)) keep.push(starter);
184
- else
185
- entries.push({
186
- kind: "file",
187
- path: starter,
188
- purpose: "portal",
189
- content: starterPage(name),
190
- });
191
- }
192
-
193
- return {
194
- dir,
195
- configPath,
196
- layout,
197
- layoutSource: options.layout ? "flag" : "detected",
198
- layoutReason,
199
- config,
200
- docsCandidates,
201
- docsIncluded,
202
- needsDocsChoice,
203
- entries,
204
- keep,
205
- replacesConfig,
206
- };
207
- }
208
-
209
- export type InitResult = {
210
- created: string[];
211
- kept: string[];
212
- };
213
-
214
- /** Writes the plan. Existing files are skipped, except the config when the plan replaces it. */
215
- export async function applyInitPlan(plan: InitPlan): Promise<InitResult> {
216
- const created: string[] = [];
217
- const kept = [...plan.keep];
218
-
219
- await mkdir(plan.dir, { recursive: true });
220
- await ensureStateDir(statePathsFor(plan.dir, plan.layout));
221
- for (const entry of plan.entries.filter((e) => e.purpose === "state"))
222
- created.push(entry.path);
223
-
224
- for (const entry of plan.entries.filter((e) => e.purpose !== "state")) {
225
- if (entry.kind === "dir") {
226
- await mkdir(entry.path, { recursive: true });
227
- created.push(entry.path);
228
- continue;
229
- }
230
- await mkdir(path.dirname(entry.path), { recursive: true });
231
- try {
232
- await writeFile(entry.path, entry.content ?? "", {
233
- flag: entry.purpose === "config" && plan.replacesConfig ? "w" : "wx",
234
- });
235
- created.push(entry.path);
236
- } catch (error) {
237
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
238
- kept.push(entry.path);
239
- }
240
- }
241
-
242
- return { created, kept };
243
- }
244
-
245
- /** The "will create" list shown before anything is written. */
246
- export function formatInitPlan(plan: InitPlan): string[] {
247
- const rel = (target: string) =>
248
- path.relative(plan.dir, target).split(path.sep).join("/") || ".";
249
- const lines = [
250
- `Layout: ${plan.layout} (${plan.layoutReason})`,
251
- plan.docsIncluded.length > 0
252
- ? `Existing docs are used in place: ${plan.docsIncluded.join(", ")}`
253
- : "No existing docs found: a starter page is created.",
254
- "Will create:",
255
- ...plan.entries
256
- .filter((entry) => !(entry.kind === "dir" && entry.purpose === "state"))
257
- .map(
258
- (entry) =>
259
- ` ${rel(entry.path)}${plan.replacesConfig && entry.purpose === "config" ? " (replaces the existing file)" : ""}`,
260
- ),
261
- ` ${rel(statePathsFor(plan.dir, plan.layout).dir)}/generated/ and build/ (generated state, ignored by git)`,
262
- ];
263
- if (plan.keep.length > 0) {
264
- lines.push(
265
- "Will keep as they are:",
266
- ...plan.keep.map((target) => ` ${rel(target)}`),
267
- );
268
- }
269
- lines.push(
270
- "Your own files are never moved, renamed or edited, and your .gitignore is left alone.",
271
- );
272
- return lines;
273
- }
1
+ import { access, mkdir, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ApitogoError } from "../../lib/util/invariant.js";
4
+ import { APITOGO_CONFIG_FILENAME } from "../apitogo-config-core.js";
5
+ import { parseConfigV2 } from "./parse.js";
6
+ import { layoutRoot } from "./paths.js";
7
+ import type { ConfigV2 } from "./schema.js";
8
+
9
+ export class InitError extends ApitogoError {
10
+ constructor(message: string, developerHint?: string) {
11
+ super(message, { developerHint, title: "Cannot initialise the project" });
12
+ this.name = "InitError";
13
+ }
14
+ }
15
+
16
+ export type InitOptions = {
17
+ /** Project directory; created if it does not exist. */
18
+ dir: string;
19
+ /** Project name; defaults to the directory name. */
20
+ name?: string;
21
+ /** Replace an existing `apitogo.config.json`. It is the only existing file `init` ever replaces. */
22
+ force?: boolean;
23
+ };
24
+
25
+ export type PlannedEntry = {
26
+ kind: "dir" | "file";
27
+ /** Absolute path. */
28
+ path: string;
29
+ purpose: "config" | "root";
30
+ content?: string;
31
+ };
32
+
33
+ export type InitPlan = {
34
+ dir: string;
35
+ configPath: string;
36
+ /** The config that will be written; already validated against the v2 schema. */
37
+ config: ConfigV2;
38
+ /** Everything `init` will create. */
39
+ entries: PlannedEntry[];
40
+ /** Files that already exist and stay untouched. */
41
+ keep: string[];
42
+ /** An existing config will be replaced (only with `force`). */
43
+ replacesConfig: boolean;
44
+ };
45
+
46
+ async function exists(target: string): Promise<boolean> {
47
+ try {
48
+ await access(target);
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ export function starterPage(name: string): string {
56
+ return `---
57
+ title: Welcome
58
+ ---
59
+
60
+ # ${name}
61
+
62
+ This page was created by \`apitogo init\`. Edit it, and add more \`.md\` or \`.mdx\` files next to it: each one becomes a page of your portal.
63
+ `;
64
+ }
65
+
66
+ /** Turns paths the user named into globs, checking that each exists inside the project. */
67
+ export async function docsFromPaths(
68
+ dir: string,
69
+ requested: readonly string[],
70
+ ): Promise<string[]> {
71
+ const globs: string[] = [];
72
+ for (const requestedPath of requested) {
73
+ const absolute = path.resolve(dir, requestedPath);
74
+ const relative = path.relative(dir, absolute).split(path.sep).join("/");
75
+ if (
76
+ relative === "" ||
77
+ relative.startsWith("..") ||
78
+ path.isAbsolute(relative)
79
+ ) {
80
+ throw new InitError(
81
+ `Docs path "${requestedPath}" is outside the project directory.`,
82
+ );
83
+ }
84
+ const info = await stat(absolute).catch(() => undefined);
85
+ if (!info)
86
+ throw new InitError(
87
+ `Docs path "${requestedPath}" does not exist in ${dir}.`,
88
+ );
89
+ globs.push(info.isDirectory() ? `${relative}/**/*.{md,mdx}` : relative);
90
+ }
91
+ return globs;
92
+ }
93
+
94
+ /**
95
+ * Decides what `init` would do without touching the disk (Reference, section 1.2): the config and the `apitogo/`
96
+ * folder with its own `.gitignore`, nothing else. Content and detection belong to `add`. Existing files are never
97
+ * moved, renamed or modified; the root `.gitignore` is never edited.
98
+ */
99
+ export async function planInit(options: InitOptions): Promise<InitPlan> {
100
+ const dir = path.resolve(options.dir);
101
+ const configPath = path.join(dir, APITOGO_CONFIG_FILENAME);
102
+
103
+ const replacesConfig = await exists(configPath);
104
+ if (replacesConfig && !options.force) {
105
+ throw new InitError(
106
+ `${configPath} already exists.`,
107
+ "Use `apitogo add <service>` to add to it, or pass --force to replace it. Nothing else in the project is changed.",
108
+ );
109
+ }
110
+
111
+ const name = options.name?.trim() || path.basename(dir);
112
+ const config = parseConfigV2({ version: 2, project: { name } });
113
+ const root = layoutRoot(dir);
114
+ const gitignore = path.join(root, ".gitignore");
115
+
116
+ const entries: PlannedEntry[] = [
117
+ {
118
+ kind: "file",
119
+ path: configPath,
120
+ purpose: "config",
121
+ content: `${JSON.stringify(config, null, 2)}\n`,
122
+ },
123
+ { kind: "file", path: gitignore, purpose: "root", content: ".state/\n" },
124
+ ];
125
+ const keep = (await exists(gitignore)) ? [gitignore] : [];
126
+
127
+ return { dir, configPath, config, entries, keep, replacesConfig };
128
+ }
129
+
130
+ export type InitResult = {
131
+ created: string[];
132
+ kept: string[];
133
+ };
134
+
135
+ /** Writes the plan. Existing files are skipped, except the config when the plan replaces it. */
136
+ export async function applyInitPlan(plan: InitPlan): Promise<InitResult> {
137
+ const created: string[] = [];
138
+ const kept: string[] = [];
139
+
140
+ await mkdir(plan.dir, { recursive: true });
141
+ for (const entry of plan.entries) {
142
+ await mkdir(path.dirname(entry.path), { recursive: true });
143
+ try {
144
+ await writeFile(entry.path, entry.content ?? "", {
145
+ flag: entry.purpose === "config" && plan.replacesConfig ? "w" : "wx",
146
+ });
147
+ created.push(entry.path);
148
+ } catch (error) {
149
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
150
+ kept.push(entry.path);
151
+ }
152
+ }
153
+
154
+ return { created, kept };
155
+ }
156
+
157
+ /** The "will create" list shown before anything is written. */
158
+ export function formatInitPlan(plan: InitPlan): string[] {
159
+ const rel = (target: string) =>
160
+ path.relative(plan.dir, target).split(path.sep).join("/") || ".";
161
+ const lines = [
162
+ "Will create:",
163
+ ...plan.entries
164
+ .filter((entry) => !plan.keep.includes(entry.path))
165
+ .map(
166
+ (entry) =>
167
+ ` ${rel(entry.path)}${plan.replacesConfig && entry.purpose === "config" ? " (replaces the existing file)" : ""}`,
168
+ ),
169
+ ];
170
+ if (plan.keep.length > 0) {
171
+ lines.push(
172
+ "Will keep as they are:",
173
+ ...plan.keep.map((target) => ` ${rel(target)}`),
174
+ );
175
+ }
176
+ lines.push(
177
+ "Your own files are never moved, renamed or edited, and your .gitignore is left alone.",
178
+ );
179
+ return lines;
180
+ }