@lukoweb/apitogo 0.2.42 → 0.2.44

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.
@@ -1,452 +1,472 @@
1
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
- import path from "node:path";
3
- import { z } from "zod";
4
- import { ConfigError, type ConfigIssue } from "./errors.js";
5
- import { manifestPathFor, type StatePaths } from "./paths.js";
6
- import type { ResolvedConfigV2 } from "./resolve.js";
7
-
8
- /** Highest `schemaVersion` this build of the portal understands. */
9
- export const SUPPORTED_MANIFEST_SCHEMA = 1;
10
-
11
- /** Object of booleans with camelCase names. A name that is absent means false (decision 19). */
12
- const Capabilities = z.record(z.string(), z.boolean()).default({});
13
-
14
- const Status = z.string().optional();
15
-
16
- /**
17
- * The service manifest, `services.json` (Reference, section 4.1). Every service owns its entry; a service that is
18
- * missing from the manifest is disabled or not yet deployed. Unknown fields are kept so a newer deploy does not
19
- * break an older reader.
20
- */
21
- const ServiceManifestSchema = z.looseObject({
22
- schemaVersion: z.number().int(),
23
- generatedAt: z.string().optional(),
24
- /** Set only on the stub written for local runs; never present in a deployed manifest. */
25
- preview: z.boolean().optional(),
26
- project: z.looseObject({ id: z.string().optional(), name: z.string() }),
27
- services: z
28
- .looseObject({
29
- portal: z
30
- .looseObject({
31
- url: z.string().optional(),
32
- docs: z
33
- .looseObject({ provider: z.string(), url: z.string().optional() })
34
- .optional(),
35
- console: z
36
- .looseObject({ links: z.record(z.string(), z.string()).optional() })
37
- .optional(),
38
- })
39
- .optional(),
40
- identity: z
41
- .looseObject({
42
- provider: z.string().default("apitogo"),
43
- status: Status,
44
- issuer: z.string().optional(),
45
- clientId: z.string().optional(),
46
- scopes: z.array(z.string()).optional(),
47
- audience: z.string().optional(),
48
- capabilities: Capabilities,
49
- })
50
- .optional(),
51
- gateway: z
52
- .looseObject({
53
- provider: z.string().optional(),
54
- status: Status,
55
- baseUrl: z.string().optional(),
56
- openapiUrl: z.string().optional(),
57
- openapiHash: z.string().optional(),
58
- audience: z.string().optional(),
59
- keyHeaderName: z.string().optional(),
60
- capabilities: Capabilities,
61
- })
62
- .optional(),
63
- plans: z
64
- .looseObject({
65
- provider: z.string().optional(),
66
- status: Status,
67
- apiBaseUrl: z.string().optional(),
68
- keysApiBaseUrl: z.string().optional(),
69
- billing: z.string().optional(),
70
- audience: z.string().optional(),
71
- scopes: z.array(z.string()).optional(),
72
- catalogRevision: z.number().int().optional(),
73
- capabilities: Capabilities,
74
- })
75
- .optional(),
76
- })
77
- .default({}),
78
- });
79
-
80
- export type ServiceManifest = z.infer<typeof ServiceManifestSchema>;
81
- export type ManifestServiceName = "identity" | "gateway" | "plans" | "portal";
82
-
83
- const issue = (
84
- code: string,
85
- path: string,
86
- message: string,
87
- hint?: string,
88
- ): ConfigIssue => ({ code, path, message, hint });
89
-
90
- /** Validates parsed `services.json` content. Throws a ConfigError with stable codes. */
91
- export function parseManifest(
92
- raw: unknown,
93
- file = "services.json",
94
- ): ServiceManifest {
95
- const version =
96
- typeof raw === "object" && raw !== null && "schemaVersion" in raw
97
- ? raw.schemaVersion
98
- : undefined;
99
- if (typeof version === "number" && version > SUPPORTED_MANIFEST_SCHEMA) {
100
- throw new ConfigError(file, [
101
- issue(
102
- "PORTAL_MANIFEST_SCHEMA",
103
- "schemaVersion",
104
- `services.json has schemaVersion ${version} but this CLI supports ${SUPPORTED_MANIFEST_SCHEMA}.`,
105
- "Upgrade @lukoweb/apitogo.",
106
- ),
107
- ]);
108
- }
109
- const result = ServiceManifestSchema.safeParse(raw);
110
- if (!result.success) {
111
- throw new ConfigError(
112
- file,
113
- result.error.issues.map((problem) =>
114
- issue(
115
- "PORTAL_MANIFEST_INVALID",
116
- problem.path.join("."),
117
- problem.message,
118
- "Run `apitogo deploy` to write a fresh services.json.",
119
- ),
120
- ),
121
- );
122
- }
123
- return result.data;
124
- }
125
-
126
- /** Reads `services.json` from the state directory. Returns undefined when the file does not exist. */
127
- export async function readManifest(
128
- state: Pick<StatePaths, "dir">,
129
- ): Promise<ServiceManifest | undefined> {
130
- const file = manifestPathFor(state);
131
- let text: string;
132
- try {
133
- text = await readFile(file, "utf8");
134
- } catch (error) {
135
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
136
- throw error;
137
- }
138
- try {
139
- return parseManifest(JSON.parse(text), file);
140
- } catch (error) {
141
- if (error instanceof SyntaxError) {
142
- throw new ConfigError(file, [
143
- issue(
144
- "PORTAL_MANIFEST_INVALID",
145
- "",
146
- `is not valid JSON (${error.message}).`,
147
- "Run `apitogo deploy` to write a fresh services.json.",
148
- ),
149
- ]);
150
- }
151
- throw error;
152
- }
153
- }
154
-
155
- type Entry = { status?: string; capabilities: Record<string, boolean> };
156
-
157
- /** A service is usable when it has an entry and is not still provisioning. A missing status counts as ready. */
158
- export function serviceReady(entry: { status?: string } | undefined): boolean {
159
- return entry !== undefined && (entry.status ?? "ready") === "ready";
160
- }
161
-
162
- /** True only when the service exists, is ready and declares the capability. Absent means false. */
163
- export function hasCapability(entry: Entry | undefined, name: string): boolean {
164
- return serviceReady(entry) && entry?.capabilities[name] === true;
165
- }
166
-
167
- /**
168
- * Services the configured portal depends on. A production build needs a manifest entry for each of them
169
- * (PORTAL_MANIFEST_MISSING); a docs-only portal with no gateway needs none.
170
- */
171
- export function requiredServices(
172
- resolved: ResolvedConfigV2,
173
- ): ManifestServiceName[] {
174
- const portal = resolved.portal;
175
- if (!portal?.enabled) return [];
176
- const needs: ManifestServiceName[] = [];
177
- const consoleOn = portal.console.enabled;
178
- const gatewayOn = resolved.gateway?.enabled === true;
179
- const identityOn = resolved.identity?.enabled === true;
180
- if (identityOn && (consoleOn || portal.requireAuth)) needs.push("identity");
181
- if (gatewayOn && (consoleOn || portal.docs.enabled)) needs.push("gateway");
182
- if (resolved.plans?.enabled === true && consoleOn) needs.push("plans");
183
- return needs;
184
- }
185
-
186
- /** Problems that stop a production build: a required service without a manifest entry. */
187
- export function missingManifestServices(
188
- resolved: ResolvedConfigV2,
189
- manifest: ServiceManifest | undefined,
190
- ): ConfigIssue[] {
191
- return requiredServices(resolved)
192
- .filter((name) => manifest?.services[name] === undefined)
193
- .map((name) =>
194
- issue(
195
- "PORTAL_MANIFEST_MISSING",
196
- `services.${name}`,
197
- `the portal needs the ${name} service, but services.json has no entry for it.`,
198
- `Run \`apitogo deploy --only ${name}\` first.`,
199
- ),
200
- );
201
- }
202
-
203
- /**
204
- * The stand-in manifest for local runs before identity and plans exist. It is labelled `preview: true`, uses
205
- * the demo issuer-less shape (no URLs are invented) and is refused by production builds.
206
- */
207
- export function stubManifest(resolved: ResolvedConfigV2): ServiceManifest {
208
- const portal = resolved.portal;
209
- const consoleOn = portal?.enabled === true && portal.console.enabled;
210
- return parseManifest({
211
- schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
212
- preview: true,
213
- project: { id: resolved.project.id, name: resolved.project.name },
214
- services: {
215
- ...(consoleOn && resolved.identity?.enabled
216
- ? {
217
- identity: {
218
- provider: "apitogo",
219
- status: "ready",
220
- capabilities: { nativeSignup: true },
221
- },
222
- }
223
- : {}),
224
- ...(consoleOn && resolved.plans?.enabled
225
- ? {
226
- plans: {
227
- status: "ready",
228
- capabilities: { catalog: true },
229
- },
230
- }
231
- : {}),
232
- },
233
- });
234
- }
235
-
236
- /**
237
- * Adds what a portal deploy learned (project, public URL, docs provider) to the manifest. Entries of other services
238
- * are kept as they are, so deploying the portal never erases what identity or plans wrote.
239
- */
240
- export function withPortalEntry(
241
- existing: ServiceManifest | undefined,
242
- info: {
243
- projectId: string;
244
- projectName: string;
245
- url: string;
246
- docs: { provider: string; url?: string };
247
- now?: Date;
248
- },
249
- ): ServiceManifest {
250
- const base: ServiceManifest =
251
- existing ??
252
- parseManifest({
253
- schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
254
- project: { name: info.projectName },
255
- });
256
- const previousPortal = base.services.portal;
257
- return {
258
- ...base,
259
- schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
260
- generatedAt: (info.now ?? new Date()).toISOString(),
261
- preview: undefined,
262
- project: { ...base.project, id: info.projectId, name: info.projectName },
263
- services: {
264
- ...base.services,
265
- portal: {
266
- ...previousPortal,
267
- url: info.url.replace(/\/+$/, ""),
268
- docs: info.docs,
269
- },
270
- },
271
- };
272
- }
273
-
274
- /**
275
- * Adds the gateway entry a gateway deploy learned. Only public values: the base URL and what the platform says the
276
- * gateway can do. Entries of other services are kept as they are.
277
- */
278
- export function withGatewayEntry(
279
- existing: ServiceManifest | undefined,
280
- info: {
281
- projectId: string;
282
- projectName: string;
283
- provider: string;
284
- baseUrl: string;
285
- capabilities: Record<string, boolean>;
286
- openapiUrl?: string;
287
- openapiHash?: string;
288
- keyHeaderName?: string;
289
- now?: Date;
290
- },
291
- ): ServiceManifest {
292
- const base: ServiceManifest =
293
- existing ??
294
- parseManifest({
295
- schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
296
- project: { name: info.projectName },
297
- });
298
- const baseUrl = info.baseUrl.replace(/\/+$/, "");
299
- return {
300
- ...base,
301
- schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
302
- generatedAt: (info.now ?? new Date()).toISOString(),
303
- preview: undefined,
304
- project: { ...base.project, id: info.projectId, name: info.projectName },
305
- services: {
306
- ...base.services,
307
- gateway: {
308
- ...base.services.gateway,
309
- provider: info.provider,
310
- status: "ready",
311
- baseUrl,
312
- audience: baseUrl,
313
- capabilities: info.capabilities,
314
- ...(info.openapiUrl ? { openapiUrl: info.openapiUrl } : {}),
315
- ...(info.openapiHash ? { openapiHash: info.openapiHash } : {}),
316
- ...(info.keyHeaderName ? { keyHeaderName: info.keyHeaderName } : {}),
317
- },
318
- },
319
- };
320
- }
321
-
322
- /**
323
- * Adds the identity entry an identity deploy learned: the issuer address, the portal's sign-in client and the audience
324
- * of its tokens. Public values only. Entries of other services are kept as they are.
325
- */
326
- export function withIdentityEntry(
327
- existing: ServiceManifest | undefined,
328
- info: {
329
- projectId: string;
330
- projectName: string;
331
- /** `apitogo` for the platform's own identity, `oidc` for the customer's provider (nothing is made for it). */
332
- provider?: "apitogo" | "oidc";
333
- issuer: string;
334
- clientId: string;
335
- audience?: string;
336
- nativeSignup: boolean;
337
- now?: Date;
338
- },
339
- ): ServiceManifest {
340
- const base: ServiceManifest =
341
- existing ??
342
- parseManifest({
343
- schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
344
- project: { name: info.projectName },
345
- });
346
- return {
347
- ...base,
348
- schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
349
- generatedAt: (info.now ?? new Date()).toISOString(),
350
- preview: undefined,
351
- project: { ...base.project, id: info.projectId, name: info.projectName },
352
- services: {
353
- ...base.services,
354
- identity: {
355
- ...base.services.identity,
356
- provider: info.provider ?? "apitogo",
357
- status: "ready",
358
- issuer: info.issuer.replace(/\/+$/, ""),
359
- clientId: info.clientId,
360
- scopes: ["openid", "profile", "email"],
361
- ...(info.audience ? { audience: info.audience } : {}),
362
- capabilities: { nativeSignup: info.nativeSignup },
363
- },
364
- },
365
- };
366
- }
367
-
368
- /**
369
- * Adds the plans entry a plans deploy learned: where the portal reaches the plans backend and what it can do. Public
370
- * values only. Entries of other services are kept as they are.
371
- */
372
- export function withPlansEntry(
373
- existing: ServiceManifest | undefined,
374
- info: {
375
- projectId: string;
376
- projectName: string;
377
- apiBaseUrl: string;
378
- billing: string;
379
- catalogRevision: number;
380
- capabilities: Record<string, boolean>;
381
- now?: Date;
382
- },
383
- ): ServiceManifest {
384
- const base: ServiceManifest =
385
- existing ??
386
- parseManifest({
387
- schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
388
- project: { name: info.projectName },
389
- });
390
- return {
391
- ...base,
392
- schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
393
- generatedAt: (info.now ?? new Date()).toISOString(),
394
- preview: undefined,
395
- project: { ...base.project, id: info.projectId, name: info.projectName },
396
- services: {
397
- ...base.services,
398
- plans: {
399
- ...base.services.plans,
400
- provider: "apitogo",
401
- status: "ready",
402
- apiBaseUrl: info.apiBaseUrl.replace(/\/+$/, ""),
403
- billing: info.billing,
404
- catalogRevision: info.catalogRevision,
405
- capabilities: info.capabilities,
406
- },
407
- },
408
- };
409
- }
410
-
411
- /** Replaces `services.json` atomically. Public values only: this file ships inside the portal. */
412
- export async function writeManifest(
413
- state: Pick<StatePaths, "dir">,
414
- manifest: ServiceManifest,
415
- ): Promise<void> {
416
- const file = manifestPathFor(state);
417
- await mkdir(path.dirname(file), { recursive: true });
418
- const temporary = `${file}.${process.pid}.tmp`;
419
- try {
420
- await writeFile(
421
- temporary,
422
- `${JSON.stringify(manifest, null, 2)}\n`,
423
- "utf8",
424
- );
425
- await rename(temporary, file);
426
- } catch (error) {
427
- await rm(temporary, { force: true });
428
- throw error;
429
- }
430
- }
431
-
432
- /**
433
- * Drops the entries of services that were torn down. The file is deleted when no service is left, because a
434
- * manifest with no services says nothing and would only look like a deployed project.
435
- */
436
- export async function removeServicesFromManifest(
437
- state: Pick<StatePaths, "dir">,
438
- names: readonly ManifestServiceName[],
439
- ): Promise<void> {
440
- const existing = await readManifest(state);
441
- if (!existing) return;
442
- const services = Object.fromEntries(
443
- Object.entries(existing.services).filter(
444
- ([name]) => !(names as readonly string[]).includes(name),
445
- ),
446
- );
447
- if (Object.keys(services).length === 0) {
448
- await rm(manifestPathFor(state), { force: true });
449
- return;
450
- }
451
- await writeManifest(state, { ...existing, services });
452
- }
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { ConfigError, type ConfigIssue } from "./errors.js";
5
+ import { manifestPathFor, type StatePaths } from "./paths.js";
6
+ import type { ResolvedConfigV2 } from "./resolve.js";
7
+
8
+ /** Highest `schemaVersion` this build of the portal understands. */
9
+ export const SUPPORTED_MANIFEST_SCHEMA = 1;
10
+
11
+ /** Object of booleans with camelCase names. A name that is absent means false (decision 19). */
12
+ const Capabilities = z.record(z.string(), z.boolean()).default({});
13
+
14
+ const Status = z.string().optional();
15
+
16
+ /**
17
+ * The service manifest, `services.json` (Reference, section 4.1). Every service owns its entry; a service that is
18
+ * missing from the manifest is disabled or not yet deployed. Unknown fields are kept so a newer deploy does not
19
+ * break an older reader.
20
+ */
21
+ const ServiceManifestSchema = z.looseObject({
22
+ schemaVersion: z.number().int(),
23
+ generatedAt: z.string().optional(),
24
+ /** Set only on the stub written for local runs; never present in a deployed manifest. */
25
+ preview: z.boolean().optional(),
26
+ project: z.looseObject({ id: z.string().optional(), name: z.string() }),
27
+ services: z
28
+ .looseObject({
29
+ portal: z
30
+ .looseObject({
31
+ url: z.string().optional(),
32
+ docs: z
33
+ .looseObject({ provider: z.string(), url: z.string().optional() })
34
+ .optional(),
35
+ console: z
36
+ .looseObject({ links: z.record(z.string(), z.string()).optional() })
37
+ .optional(),
38
+ })
39
+ .optional(),
40
+ identity: z
41
+ .looseObject({
42
+ provider: z.string().default("apitogo"),
43
+ status: Status,
44
+ issuer: z.string().optional(),
45
+ clientId: z.string().optional(),
46
+ scopes: z.array(z.string()).optional(),
47
+ audience: z.string().optional(),
48
+ capabilities: Capabilities,
49
+ })
50
+ .optional(),
51
+ gateway: z
52
+ .looseObject({
53
+ provider: z.string().optional(),
54
+ status: Status,
55
+ baseUrl: z.string().optional(),
56
+ openapiUrl: z.string().optional(),
57
+ openapiHash: z.string().optional(),
58
+ audience: z.string().optional(),
59
+ keyHeaderName: z.string().optional(),
60
+ capabilities: Capabilities,
61
+ })
62
+ .optional(),
63
+ plans: z
64
+ .looseObject({
65
+ provider: z.string().optional(),
66
+ status: Status,
67
+ apiBaseUrl: z.string().optional(),
68
+ keysApiBaseUrl: z.string().optional(),
69
+ billing: z.string().optional(),
70
+ audience: z.string().optional(),
71
+ scopes: z.array(z.string()).optional(),
72
+ catalogRevision: z.number().int().optional(),
73
+ capabilities: Capabilities,
74
+ })
75
+ .optional(),
76
+ })
77
+ .default({}),
78
+ });
79
+
80
+ export type ServiceManifest = z.infer<typeof ServiceManifestSchema>;
81
+ export type ManifestServiceName = "identity" | "gateway" | "plans" | "portal";
82
+
83
+ const issue = (
84
+ code: string,
85
+ path: string,
86
+ message: string,
87
+ hint?: string,
88
+ ): ConfigIssue => ({ code, path, message, hint });
89
+
90
+ /** Validates parsed `services.json` content. Throws a ConfigError with stable codes. */
91
+ export function parseManifest(
92
+ raw: unknown,
93
+ file = "services.json",
94
+ ): ServiceManifest {
95
+ const version =
96
+ typeof raw === "object" && raw !== null && "schemaVersion" in raw
97
+ ? raw.schemaVersion
98
+ : undefined;
99
+ if (typeof version === "number" && version > SUPPORTED_MANIFEST_SCHEMA) {
100
+ throw new ConfigError(file, [
101
+ issue(
102
+ "PORTAL_MANIFEST_SCHEMA",
103
+ "schemaVersion",
104
+ `services.json has schemaVersion ${version} but this CLI supports ${SUPPORTED_MANIFEST_SCHEMA}.`,
105
+ "Upgrade @lukoweb/apitogo.",
106
+ ),
107
+ ]);
108
+ }
109
+ const result = ServiceManifestSchema.safeParse(raw);
110
+ if (!result.success) {
111
+ throw new ConfigError(
112
+ file,
113
+ result.error.issues.map((problem) =>
114
+ issue(
115
+ "PORTAL_MANIFEST_INVALID",
116
+ problem.path.join("."),
117
+ problem.message,
118
+ "Run `apitogo deploy` to write a fresh services.json.",
119
+ ),
120
+ ),
121
+ );
122
+ }
123
+ return result.data;
124
+ }
125
+
126
+ /** Reads `services.json` from the state directory. Returns undefined when the file does not exist. */
127
+ export async function readManifest(
128
+ state: Pick<StatePaths, "dir">,
129
+ ): Promise<ServiceManifest | undefined> {
130
+ const file = manifestPathFor(state);
131
+ let text: string;
132
+ try {
133
+ text = await readFile(file, "utf8");
134
+ } catch (error) {
135
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
136
+ throw error;
137
+ }
138
+ try {
139
+ return parseManifest(JSON.parse(text), file);
140
+ } catch (error) {
141
+ if (error instanceof SyntaxError) {
142
+ throw new ConfigError(file, [
143
+ issue(
144
+ "PORTAL_MANIFEST_INVALID",
145
+ "",
146
+ `is not valid JSON (${error.message}).`,
147
+ "Run `apitogo deploy` to write a fresh services.json.",
148
+ ),
149
+ ]);
150
+ }
151
+ throw error;
152
+ }
153
+ }
154
+
155
+ type Entry = { status?: string; capabilities: Record<string, boolean> };
156
+
157
+ /** A service is usable when it has an entry and is not still provisioning. A missing status counts as ready. */
158
+ export function serviceReady(entry: { status?: string } | undefined): boolean {
159
+ return entry !== undefined && (entry.status ?? "ready") === "ready";
160
+ }
161
+
162
+ /** True only when the service exists, is ready and declares the capability. Absent means false. */
163
+ export function hasCapability(entry: Entry | undefined, name: string): boolean {
164
+ return serviceReady(entry) && entry?.capabilities[name] === true;
165
+ }
166
+
167
+ /**
168
+ * Services the configured portal depends on. A production build needs a manifest entry for each of them
169
+ * (PORTAL_MANIFEST_MISSING); a docs-only portal with no gateway needs none.
170
+ */
171
+ export function requiredServices(
172
+ resolved: ResolvedConfigV2,
173
+ ): ManifestServiceName[] {
174
+ const portal = resolved.portal;
175
+ if (!portal?.enabled) return [];
176
+ const needs: ManifestServiceName[] = [];
177
+ const consoleOn = portal.console.enabled;
178
+ const gatewayOn = resolved.gateway?.enabled === true;
179
+ const identityOn = resolved.identity?.enabled === true;
180
+ if (identityOn && (consoleOn || portal.requireAuth)) needs.push("identity");
181
+ if (gatewayOn && (consoleOn || portal.docs.enabled)) needs.push("gateway");
182
+ if (resolved.plans?.enabled === true && consoleOn) needs.push("plans");
183
+ return needs;
184
+ }
185
+
186
+ /** Problems that stop a production build: a required service without a manifest entry. */
187
+ export function missingManifestServices(
188
+ resolved: ResolvedConfigV2,
189
+ manifest: ServiceManifest | undefined,
190
+ ): ConfigIssue[] {
191
+ return requiredServices(resolved)
192
+ .filter((name) => manifest?.services[name] === undefined)
193
+ .map((name) =>
194
+ issue(
195
+ "PORTAL_MANIFEST_MISSING",
196
+ `services.${name}`,
197
+ `the portal needs the ${name} service, but services.json has no entry for it.`,
198
+ `Run \`apitogo deploy --only ${name}\` first.`,
199
+ ),
200
+ );
201
+ }
202
+
203
+ /** Where a managed origin listens when it runs on this machine: the port the platform's hosting also uses. */
204
+ export const PREVIEW_MANAGED_ORIGIN_URL = "http://localhost:8080";
205
+
206
+ /**
207
+ * The address the API page of a preview calls before the gateway is deployed: the API itself. A proxy origin is
208
+ * reachable at its own URL; a managed origin is the user's code, run on this machine (`npm start` in its folder).
209
+ */
210
+ function previewGatewayUrl(resolved: ResolvedConfigV2): string | undefined {
211
+ const gateway = resolved.gateway;
212
+ if (gateway?.enabled !== true) return undefined;
213
+ return gateway.origin.mode === "proxy"
214
+ ? gateway.origin.url
215
+ : PREVIEW_MANAGED_ORIGIN_URL;
216
+ }
217
+
218
+ /**
219
+ * The stand-in manifest for local runs before identity and plans exist. It is labelled `preview: true` and is
220
+ * refused by production builds. Sign-in gets no invented issuer; the API page calls the API directly, since the
221
+ * gateway has no address yet.
222
+ */
223
+ export function stubManifest(resolved: ResolvedConfigV2): ServiceManifest {
224
+ const portal = resolved.portal;
225
+ const consoleOn = portal?.enabled === true && portal.console.enabled;
226
+ const gatewayUrl = previewGatewayUrl(resolved);
227
+ return parseManifest({
228
+ schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
229
+ preview: true,
230
+ project: { id: resolved.project.id, name: resolved.project.name },
231
+ services: {
232
+ ...(gatewayUrl
233
+ ? { gateway: { status: "ready", baseUrl: gatewayUrl } }
234
+ : {}),
235
+ ...(consoleOn && resolved.identity?.enabled
236
+ ? {
237
+ identity: {
238
+ provider: "apitogo",
239
+ status: "ready",
240
+ capabilities: { nativeSignup: true },
241
+ },
242
+ }
243
+ : {}),
244
+ ...(consoleOn && resolved.plans?.enabled
245
+ ? {
246
+ plans: {
247
+ status: "ready",
248
+ capabilities: { catalog: true },
249
+ },
250
+ }
251
+ : {}),
252
+ },
253
+ });
254
+ }
255
+
256
+ /**
257
+ * Adds what a portal deploy learned (project, public URL, docs provider) to the manifest. Entries of other services
258
+ * are kept as they are, so deploying the portal never erases what identity or plans wrote.
259
+ */
260
+ export function withPortalEntry(
261
+ existing: ServiceManifest | undefined,
262
+ info: {
263
+ projectId: string;
264
+ projectName: string;
265
+ url: string;
266
+ docs: { provider: string; url?: string };
267
+ now?: Date;
268
+ },
269
+ ): ServiceManifest {
270
+ const base: ServiceManifest =
271
+ existing ??
272
+ parseManifest({
273
+ schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
274
+ project: { name: info.projectName },
275
+ });
276
+ const previousPortal = base.services.portal;
277
+ return {
278
+ ...base,
279
+ schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
280
+ generatedAt: (info.now ?? new Date()).toISOString(),
281
+ preview: undefined,
282
+ project: { ...base.project, id: info.projectId, name: info.projectName },
283
+ services: {
284
+ ...base.services,
285
+ portal: {
286
+ ...previousPortal,
287
+ url: info.url.replace(/\/+$/, ""),
288
+ docs: info.docs,
289
+ },
290
+ },
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Adds the gateway entry a gateway deploy learned. Only public values: the base URL and what the platform says the
296
+ * gateway can do. Entries of other services are kept as they are.
297
+ */
298
+ export function withGatewayEntry(
299
+ existing: ServiceManifest | undefined,
300
+ info: {
301
+ projectId: string;
302
+ projectName: string;
303
+ provider: string;
304
+ baseUrl: string;
305
+ capabilities: Record<string, boolean>;
306
+ openapiUrl?: string;
307
+ openapiHash?: string;
308
+ keyHeaderName?: string;
309
+ now?: Date;
310
+ },
311
+ ): ServiceManifest {
312
+ const base: ServiceManifest =
313
+ existing ??
314
+ parseManifest({
315
+ schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
316
+ project: { name: info.projectName },
317
+ });
318
+ const baseUrl = info.baseUrl.replace(/\/+$/, "");
319
+ return {
320
+ ...base,
321
+ schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
322
+ generatedAt: (info.now ?? new Date()).toISOString(),
323
+ preview: undefined,
324
+ project: { ...base.project, id: info.projectId, name: info.projectName },
325
+ services: {
326
+ ...base.services,
327
+ gateway: {
328
+ ...base.services.gateway,
329
+ provider: info.provider,
330
+ status: "ready",
331
+ baseUrl,
332
+ audience: baseUrl,
333
+ capabilities: info.capabilities,
334
+ ...(info.openapiUrl ? { openapiUrl: info.openapiUrl } : {}),
335
+ ...(info.openapiHash ? { openapiHash: info.openapiHash } : {}),
336
+ ...(info.keyHeaderName ? { keyHeaderName: info.keyHeaderName } : {}),
337
+ },
338
+ },
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Adds the identity entry an identity deploy learned: the issuer address, the portal's sign-in client and the audience
344
+ * of its tokens. Public values only. Entries of other services are kept as they are.
345
+ */
346
+ export function withIdentityEntry(
347
+ existing: ServiceManifest | undefined,
348
+ info: {
349
+ projectId: string;
350
+ projectName: string;
351
+ /** `apitogo` for the platform's own identity, `oidc` for the customer's provider (nothing is made for it). */
352
+ provider?: "apitogo" | "oidc";
353
+ issuer: string;
354
+ clientId: string;
355
+ audience?: string;
356
+ nativeSignup: boolean;
357
+ now?: Date;
358
+ },
359
+ ): ServiceManifest {
360
+ const base: ServiceManifest =
361
+ existing ??
362
+ parseManifest({
363
+ schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
364
+ project: { name: info.projectName },
365
+ });
366
+ return {
367
+ ...base,
368
+ schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
369
+ generatedAt: (info.now ?? new Date()).toISOString(),
370
+ preview: undefined,
371
+ project: { ...base.project, id: info.projectId, name: info.projectName },
372
+ services: {
373
+ ...base.services,
374
+ identity: {
375
+ ...base.services.identity,
376
+ provider: info.provider ?? "apitogo",
377
+ status: "ready",
378
+ issuer: info.issuer.replace(/\/+$/, ""),
379
+ clientId: info.clientId,
380
+ scopes: ["openid", "profile", "email"],
381
+ ...(info.audience ? { audience: info.audience } : {}),
382
+ capabilities: { nativeSignup: info.nativeSignup },
383
+ },
384
+ },
385
+ };
386
+ }
387
+
388
+ /**
389
+ * Adds the plans entry a plans deploy learned: where the portal reaches the plans backend and what it can do. Public
390
+ * values only. Entries of other services are kept as they are.
391
+ */
392
+ export function withPlansEntry(
393
+ existing: ServiceManifest | undefined,
394
+ info: {
395
+ projectId: string;
396
+ projectName: string;
397
+ apiBaseUrl: string;
398
+ billing: string;
399
+ catalogRevision: number;
400
+ capabilities: Record<string, boolean>;
401
+ now?: Date;
402
+ },
403
+ ): ServiceManifest {
404
+ const base: ServiceManifest =
405
+ existing ??
406
+ parseManifest({
407
+ schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
408
+ project: { name: info.projectName },
409
+ });
410
+ return {
411
+ ...base,
412
+ schemaVersion: SUPPORTED_MANIFEST_SCHEMA,
413
+ generatedAt: (info.now ?? new Date()).toISOString(),
414
+ preview: undefined,
415
+ project: { ...base.project, id: info.projectId, name: info.projectName },
416
+ services: {
417
+ ...base.services,
418
+ plans: {
419
+ ...base.services.plans,
420
+ provider: "apitogo",
421
+ status: "ready",
422
+ apiBaseUrl: info.apiBaseUrl.replace(/\/+$/, ""),
423
+ billing: info.billing,
424
+ catalogRevision: info.catalogRevision,
425
+ capabilities: info.capabilities,
426
+ },
427
+ },
428
+ };
429
+ }
430
+
431
+ /** Replaces `services.json` atomically. Public values only: this file ships inside the portal. */
432
+ export async function writeManifest(
433
+ state: Pick<StatePaths, "dir">,
434
+ manifest: ServiceManifest,
435
+ ): Promise<void> {
436
+ const file = manifestPathFor(state);
437
+ await mkdir(path.dirname(file), { recursive: true });
438
+ const temporary = `${file}.${process.pid}.tmp`;
439
+ try {
440
+ await writeFile(
441
+ temporary,
442
+ `${JSON.stringify(manifest, null, 2)}\n`,
443
+ "utf8",
444
+ );
445
+ await rename(temporary, file);
446
+ } catch (error) {
447
+ await rm(temporary, { force: true });
448
+ throw error;
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Drops the entries of services that were torn down. The file is deleted when no service is left, because a
454
+ * manifest with no services says nothing and would only look like a deployed project.
455
+ */
456
+ export async function removeServicesFromManifest(
457
+ state: Pick<StatePaths, "dir">,
458
+ names: readonly ManifestServiceName[],
459
+ ): Promise<void> {
460
+ const existing = await readManifest(state);
461
+ if (!existing) return;
462
+ const services = Object.fromEntries(
463
+ Object.entries(existing.services).filter(
464
+ ([name]) => !(names as readonly string[]).includes(name),
465
+ ),
466
+ );
467
+ if (Object.keys(services).length === 0) {
468
+ await rm(manifestPathFor(state), { force: true });
469
+ return;
470
+ }
471
+ await writeManifest(state, { ...existing, services });
472
+ }