@lukoweb/apitogo 0.2.2 → 0.2.12

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.
@@ -253,11 +253,64 @@ function isIpLiteral(hostname: string): boolean {
253
253
  return /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.startsWith("[");
254
254
  }
255
255
 
256
+ /** The credential the gateway sends to the origin: a header needs a name and a secret; mtls is not available yet. */
257
+ function originAuthRules(
258
+ origin: NonNullable<ResolvedConfigV2["gateway"]>["origin"],
259
+ out: Collector,
260
+ ) {
261
+ const auth = origin.auth;
262
+ if (!auth) return;
263
+ const base = "gateway.origin.auth";
264
+ if (auth.type === "header") {
265
+ if (!auth.name)
266
+ out.error({
267
+ code: "GATEWAY_ORIGIN_INCOMPLETE",
268
+ path: `${base}.name`,
269
+ message: "origin.auth.type is header, so origin.auth.name is required.",
270
+ hint: 'For example: "X-Origin-Key".',
271
+ });
272
+ if (!auth.value)
273
+ out.error({
274
+ code: "GATEWAY_ORIGIN_INCOMPLETE",
275
+ path: `${base}.value`,
276
+ message:
277
+ "origin.auth.type is header, so origin.auth.value is required.",
278
+ hint: 'A secret reference, for example "secret:ORIGIN_KEY". Store it with `apitogo secrets set ORIGIN_KEY`.',
279
+ });
280
+ } else if (auth.type === "none" && (auth.name || auth.value)) {
281
+ out.error({
282
+ code: "GATEWAY_ORIGIN_INVALID",
283
+ path: base,
284
+ message: "origin.auth.type is none, but a name or value is set.",
285
+ hint: 'Remove them, or set the type to "header".',
286
+ });
287
+ } else if (auth.type === "mtls") {
288
+ out.error({
289
+ code: "GATEWAY_CAPABILITY_UNSUPPORTED",
290
+ path: `${base}.type`,
291
+ message:
292
+ "Client certificates (mtls) for the origin are not available yet.",
293
+ hint: 'Use type "header" with a secret.',
294
+ });
295
+ }
296
+ }
297
+
256
298
  async function gatewayRules(resolved: ResolvedConfigV2, out: Collector) {
257
299
  const gateway = resolved.gateway;
258
300
  if (!gateway?.enabled) return;
259
301
  const origin = gateway.origin;
260
302
 
303
+ (gateway.policies.cors ?? []).forEach((site, index) => {
304
+ const { pathname, search, hash } = new URL(site);
305
+ if (pathname === "/" && !search && !hash && !site.endsWith("/")) return;
306
+ out.error({
307
+ code: "GATEWAY_CORS_ORIGIN_INVALID",
308
+ path: `gateway.policies.cors[${index}]`,
309
+ message: `"${site}" is not a browser origin: it has a path, a query or a trailing slash.`,
310
+ hint: `Use only scheme and host, for example "${new URL(site).origin}".`,
311
+ });
312
+ });
313
+
261
314
  if (origin.mode === "managed") {
262
315
  for (const field of ["source", "runtime", "runtimeVersion"] as const) {
263
316
  if (!origin[field]) {
@@ -340,6 +393,17 @@ async function gatewayRules(resolved: ResolvedConfigV2, out: Collector) {
340
393
  }
341
394
  }
342
395
 
396
+ originAuthRules(origin, out);
397
+ if (gateway.specs.length > 1) {
398
+ out.warn({
399
+ code: "GATEWAY_SPEC_INVALID",
400
+ path: "gateway.specs",
401
+ message:
402
+ "gateway.specs lists several files, but the platform takes one OpenAPI file per gateway, so deploy will refuse it.",
403
+ hint: "Merge them into one file.",
404
+ });
405
+ }
406
+
343
407
  for (const [index, spec] of gateway.specs.entries())
344
408
  await specRule(spec, index, resolved, out);
345
409
 
@@ -60,6 +60,20 @@ const GatewayOriginSchema = z.strictObject({
60
60
  runtimeVersion: nonEmpty.optional(),
61
61
  exclude: pathList.optional(),
62
62
  url: httpsUrl.optional(),
63
+ /** Path the platform probes to check the origin, for example `/health`. */
64
+ healthPath: z
65
+ .string()
66
+ .regex(/^\/(?!\/)/, { error: 'must start with a single "/"' })
67
+ .optional(),
68
+ timeoutSeconds: z.int().min(1).max(120).optional(),
69
+ /** What the gateway sends to the origin as credential. The value is always a secret reference. */
70
+ auth: z
71
+ .strictObject({
72
+ type: z.enum(["none", "header", "mtls"]),
73
+ name: nonEmpty.optional(),
74
+ value: secretRef.optional(),
75
+ })
76
+ .optional(),
63
77
  });
64
78
 
65
79
  export const GatewaySchema = z.strictObject({
@@ -67,6 +81,10 @@ export const GatewaySchema = z.strictObject({
67
81
  provider: nonEmpty.optional(),
68
82
  origin: GatewayOriginSchema,
69
83
  specs: z.array(path).min(1, { error: "needs at least one OpenAPI file" }),
84
+ /** Who may read the OpenAPI file the platform publishes for this gateway. Default: public. */
85
+ export: z
86
+ .strictObject({ access: z.enum(["public", "authenticated"]).optional() })
87
+ .optional(),
70
88
  auth: z
71
89
  .strictObject({
72
90
  mode: z.enum(["identity", "oidc"]),
@@ -77,19 +95,35 @@ export const GatewaySchema = z.strictObject({
77
95
  .optional(),
78
96
  policies: z
79
97
  .strictObject({
98
+ access: z
99
+ .strictObject({
100
+ jwt: z.boolean().optional(),
101
+ apiKey: z.boolean().optional(),
102
+ apiKeyHeader: nonEmpty.optional(),
103
+ bindKeyToToken: z.boolean().optional(),
104
+ })
105
+ .optional(),
80
106
  rateLimit: z
81
107
  .strictObject({
82
108
  calls: z.int().positive(),
83
109
  periodSeconds: z.int().positive(),
110
+ scope: z.enum(["key", "entitlement"]).optional(),
84
111
  })
85
112
  .optional(),
86
113
  quota: z
87
114
  .strictObject({
88
115
  calls: z.int().positive(),
89
116
  periodDays: z.int().min(1),
117
+ scope: z.enum(["key", "entitlement"]).optional(),
90
118
  })
91
119
  .optional(),
92
120
  cors: z.array(z.url()).optional(),
121
+ operations: z
122
+ .strictObject({
123
+ public: z.array(nonEmpty).optional(),
124
+ deny: z.array(nonEmpty).optional(),
125
+ })
126
+ .optional(),
93
127
  })
94
128
  .optional(),
95
129
  });
@@ -642,3 +642,20 @@ export async function setProjectId(
642
642
  await writeConfig(resolved.configPath, next);
643
643
  return true;
644
644
  }
645
+
646
+ /** Removes `project.id` when it is the given id: the project is gone, so the link would only point at nothing. */
647
+ export async function clearProjectId(
648
+ dir: string,
649
+ projectId: string,
650
+ ): Promise<boolean> {
651
+ const { config, resolved } = loadConfigV2(dir);
652
+ if (config.project?.id !== projectId) return false;
653
+ const { id: _id, ...project } = config.project;
654
+ const { project: _old, ...rest } = config;
655
+ const next = reparse(resolved.configPath, {
656
+ ...rest,
657
+ ...(Object.keys(project).length > 0 ? { project } : {}),
658
+ });
659
+ await writeConfig(resolved.configPath, next);
660
+ return true;
661
+ }
@@ -1,6 +1,10 @@
1
1
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { ConfigError, type ConfigIssue } from "./errors.js";
4
+ import {
5
+ ReferenceSpecError,
6
+ renderReferenceSpec,
7
+ } from "./gateway-reference.js";
4
8
  import {
5
9
  hasCapability,
6
10
  missingManifestServices,
@@ -43,8 +47,10 @@ const PLANS_CONSOLE_CAPABILITIES = [
43
47
  export type PortalPlan = {
44
48
  /** Docs pages, navigation and search APItoGo builds. */
45
49
  docs: boolean;
46
- /** OpenAPI reference pages and playground. */
50
+ /** OpenAPI reference pages and playground, built from the gateway's OpenAPI file. */
47
51
  apiReference: boolean;
52
+ /** Where the reference points its calls, and the header of the API key; set when `apiReference` is true. */
53
+ gatewayReference?: { baseUrl: string; keyHeaderName?: string };
48
54
  /** OpenID sign-in against the identity issuer. */
49
55
  auth?: {
50
56
  issuer: string;
@@ -144,11 +150,11 @@ export function planPortal(
144
150
  const skipped: PortalPlan["skipped"] = [];
145
151
  const skip = (piece: string, reason: string) =>
146
152
  void skipped.push({ piece, reason });
147
- const { identity, plans } = manifest.services;
153
+ const { identity, plans, gateway } = manifest.services;
148
154
 
149
155
  const plan: PortalPlan = {
150
156
  docs: profile.modules.docs,
151
- apiReference: profile.pipelines.openapi,
157
+ apiReference: false,
152
158
  userPanel: false,
153
159
  signup: false,
154
160
  skipped,
@@ -157,6 +163,22 @@ export function planPortal(
157
163
 
158
164
  if (!profile.enabled) return plan;
159
165
 
166
+ // The API page is built from the gateway's OpenAPI file and calls the gateway, so it needs the gateway's address.
167
+ if (profile.pipelines.openapi) {
168
+ if (!gateway) skip("api reference", "services.json has no gateway entry");
169
+ else if (!serviceReady(gateway))
170
+ skip("api reference", `gateway is ${gateway.status}, not ready`);
171
+ else if (!gateway.baseUrl)
172
+ skip("api reference", "the gateway entry has no base URL yet");
173
+ else {
174
+ plan.apiReference = true;
175
+ plan.gatewayReference = {
176
+ baseUrl: gateway.baseUrl,
177
+ keyHeaderName: gateway.keyHeaderName,
178
+ };
179
+ }
180
+ }
181
+
160
182
  if (!profile.console.enabled) {
161
183
  skip("console", "portal.console.enabled is false");
162
184
  return plan;
@@ -207,6 +229,12 @@ const json = (value: unknown) => JSON.stringify(value, null, 2);
207
229
  /** Name of the site config the wrapper imports, next to it in `generated/`. */
208
230
  export const SITE_CONFIG_FILE = "apitogo.config.json";
209
231
 
232
+ /** The gateway's OpenAPI document as the portal shows it, inside `generated/`. */
233
+ export const REFERENCE_SPEC_FILE = "openapi/gateway.json";
234
+
235
+ /** Route of the API reference page. */
236
+ export const API_REFERENCE_PATH = "api";
237
+
210
238
  /**
211
239
  * The site settings (title, modules, docs globs, theme, sign-in) in the shape the existing build reads from
212
240
  * `apitogo.config.json`. It is generated, never edited, and lives in `generated/`.
@@ -237,6 +265,24 @@ export function buildSiteConfig(
237
265
  },
238
266
  }
239
267
  : {}),
268
+ ...(plan.apiReference
269
+ ? {
270
+ apis: [
271
+ {
272
+ type: "file",
273
+ input: `./${REFERENCE_SPEC_FILE}`,
274
+ path: API_REFERENCE_PATH,
275
+ },
276
+ ],
277
+ navigation: [
278
+ {
279
+ type: "link",
280
+ to: `/${API_REFERENCE_PATH}`,
281
+ label: "API Reference",
282
+ },
283
+ ],
284
+ }
285
+ : {}),
240
286
  ...(theme !== undefined ? { theme } : {}),
241
287
  ...(plan.auth ? { authentication: { type: "openid", ...plan.auth } } : {}),
242
288
  ...(plan.preview ? { preview: true } : {}),
@@ -298,6 +344,8 @@ export type PreparedPortal = {
298
344
  wrapper: string;
299
345
  /** Contents of the generated site config the wrapper imports. */
300
346
  siteConfig: Record<string, unknown>;
347
+ /** The gateway's OpenAPI document for the API page (JSON text), when the plan builds one. */
348
+ referenceSpec?: string;
301
349
  /** The manifest to ship at the site root, when there is a deployed one. */
302
350
  publishedManifest?: ServiceManifest;
303
351
  /** Absolute path the wrapper was written to, under the state directory's `generated/`. */
@@ -363,6 +411,25 @@ export async function preparePortal(
363
411
  }
364
412
 
365
413
  const plan = planPortal(profile, effective);
414
+ let referenceSpec: string | undefined;
415
+ if (plan.apiReference && plan.gatewayReference && resolved.gateway) {
416
+ try {
417
+ referenceSpec = await renderReferenceSpec(
418
+ resolved.gateway,
419
+ plan.gatewayReference.baseUrl,
420
+ plan.gatewayReference.keyHeaderName,
421
+ );
422
+ } catch (error) {
423
+ if (!(error instanceof ReferenceSpecError)) throw error;
424
+ // `check` reports a bad OpenAPI file as an error. The build goes on without the page.
425
+ plan.apiReference = false;
426
+ plan.gatewayReference = undefined;
427
+ plan.skipped.push({
428
+ piece: "api reference",
429
+ reason: `${error.message} The API page is left out.`,
430
+ });
431
+ }
432
+ }
366
433
  const siteConfig = buildSiteConfig(resolved, plan, await readTheme(resolved));
367
434
  const wrapper = renderWrapper(plan);
368
435
  const wrapperPath = path.join(resolved.state.generatedDir, WRAPPER_FILE);
@@ -372,6 +439,16 @@ export async function preparePortal(
372
439
  path.join(path.dirname(wrapperPath), SITE_CONFIG_FILE),
373
440
  `${JSON.stringify(siteConfig, null, 2)}\n`,
374
441
  );
442
+ const referenceFile = path.join(
443
+ path.dirname(wrapperPath),
444
+ REFERENCE_SPEC_FILE,
445
+ );
446
+ if (referenceSpec !== undefined) {
447
+ await mkdir(path.dirname(referenceFile), { recursive: true });
448
+ await writeFile(referenceFile, referenceSpec);
449
+ } else {
450
+ await rm(referenceFile, { force: true });
451
+ }
375
452
  const temporary = `${wrapperPath}.${process.pid}.tmp`;
376
453
  try {
377
454
  await writeFile(temporary, wrapper, "utf8");
@@ -386,6 +463,7 @@ export async function preparePortal(
386
463
  plan,
387
464
  wrapper,
388
465
  siteConfig,
466
+ referenceSpec,
389
467
  // Without a console there are no console links to offer, so a docs-only portal ships no manifest.
390
468
  publishedManifest:
391
469
  manifestSource === "file" && profile.console.enabled