@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.
@@ -318,6 +318,105 @@ export interface PageFileDto {
318
318
  isFile: boolean;
319
319
  order: number;
320
320
  }
321
+ export interface GatewaySpecUploadDto {
322
+ specId: string;
323
+ sha256: string;
324
+ openApiVersion: string;
325
+ operationCount: number;
326
+ added: number;
327
+ updated: number;
328
+ removed: number;
329
+ unchanged: boolean;
330
+ }
331
+ export interface GatewayConfigAcceptedDto {
332
+ reconciliationId: string;
333
+ desiredHash: string;
334
+ state: string;
335
+ unchanged: boolean;
336
+ }
337
+ export interface GatewayReconciliationDto {
338
+ id: string;
339
+ kind: string;
340
+ state: "Pending" | "Running" | "Succeeded" | "Failed" | "Superseded";
341
+ desiredHash?: string | null;
342
+ errorCode?: string | null;
343
+ errorMessage?: string | null;
344
+ createdAtUtc: string;
345
+ startedAtUtc?: string | null;
346
+ finishedAtUtc?: string | null;
347
+ }
348
+ export interface GatewayControlStatusDto {
349
+ provider: string;
350
+ tier: string;
351
+ status: string;
352
+ baseUrl: string;
353
+ desiredHash?: string | null;
354
+ observedHash?: string | null;
355
+ inSync: boolean;
356
+ origin?: {
357
+ mode: string;
358
+ url?: string | null;
359
+ health: string;
360
+ lastProbeAtUtc?: string | null;
361
+ latencyMs?: number | null;
362
+ lastErrorCode?: string | null;
363
+ healthChangedAtUtc?: string | null;
364
+ } | null;
365
+ operationCount: number;
366
+ specSha256?: string | null;
367
+ cors?: {
368
+ origin: string;
369
+ source: string;
370
+ applied: boolean;
371
+ }[] | null;
372
+ openApiPath?: string | null;
373
+ openApiHash?: string | null;
374
+ exportAccess?: string | null;
375
+ latestReconciliation?: GatewayReconciliationDto | null;
376
+ }
377
+ export interface GatewayCapabilitiesResponse {
378
+ provider: string;
379
+ tier: string;
380
+ capabilities: Record<string, boolean>;
381
+ }
382
+ export interface GatewayCheckResultDto {
383
+ ok: boolean;
384
+ tier: string;
385
+ issues: {
386
+ code: string;
387
+ field: string;
388
+ message: string;
389
+ hint: string;
390
+ }[];
391
+ specOperationCount?: number | null;
392
+ }
393
+ export interface GatewayDryRunDto {
394
+ ok: boolean;
395
+ issues: {
396
+ code: string;
397
+ field: string;
398
+ message: string;
399
+ hint: string;
400
+ }[];
401
+ changes?: {
402
+ wouldCreate: boolean;
403
+ inSync: boolean;
404
+ operationsAdded: string[];
405
+ operationsRemoved: string[];
406
+ nowPublic: string[];
407
+ nowProtected: string[];
408
+ nowDenied: string[];
409
+ originChanged: boolean;
410
+ policyChanges: string[];
411
+ hasChanges: boolean;
412
+ } | null;
413
+ activeKeys: number;
414
+ comparedWithGateway: boolean;
415
+ }
416
+ export interface GatewayTeardownAcceptedDto {
417
+ reconciliationId: string;
418
+ state: string;
419
+ }
321
420
  export interface ProjectSecretDto {
322
421
  name: string;
323
422
  updatedAtUtc: string;
@@ -407,6 +506,21 @@ export declare class PlatformApiClient {
407
506
  setCustomDomain(projectId: string, body: SetUserCustomDomainRequest): Promise<UserCustomDomainResponse>;
408
507
  deleteCustomDomain(projectId: string): Promise<void>;
409
508
  updateBackendOpenApi(projectId: string, openApiPath: string): Promise<BackendDeploymentDetailDto>;
509
+ putGatewaySpec(projectId: string, content: string | Uint8Array): Promise<GatewaySpecUploadDto>;
510
+ putGatewayConfig(projectId: string, body: unknown): Promise<GatewayConfigAcceptedDto>;
511
+ getGatewayReconciliation(projectId: string, reconciliationId: string): Promise<GatewayReconciliationDto>;
512
+ getGatewayStatus(projectId: string): Promise<GatewayControlStatusDto>;
513
+ getGatewayOpenApi(projectId: string): Promise<string>;
514
+ getGatewayCapabilities(projectId: string): Promise<GatewayCapabilitiesResponse>;
515
+ checkGateway(projectId: string, body: {
516
+ config: unknown;
517
+ spec?: string;
518
+ }): Promise<GatewayCheckResultDto>;
519
+ planGateway(projectId: string, body: {
520
+ config: unknown;
521
+ spec?: string;
522
+ }): Promise<GatewayDryRunDto>;
523
+ deleteGateway(projectId: string): Promise<GatewayTeardownAcceptedDto>;
410
524
  getDevPortalBackend(projectId: string): Promise<DevPortalBackendDto | null>;
411
525
  getDevPortalSetupStatus(projectId: string): Promise<DevPortalSetupStatusDto>;
412
526
  getDevPortalSettings(projectId: string): Promise<DevPortalSettingsDto | null>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lukoweb/apitogo",
3
- "version": "0.2.2",
3
+ "version": "0.2.12",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -0,0 +1,220 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parse } from "yaml";
3
+ import type { ResolvedGateway } from "./resolve.js";
4
+
5
+ /** What the gateway checks, in the terms the exported OpenAPI file uses. */
6
+ export type ReferenceOptions = {
7
+ /** The address callers use. Replaces every server of the spec. */
8
+ gatewayBaseUrl: string;
9
+ /** The customer's own API address. It must never appear in the result. */
10
+ originUrl?: string;
11
+ jwtRequired: boolean;
12
+ oidcMetadataUrl?: string;
13
+ apiKeyRequired: boolean;
14
+ apiKeyHeader: string;
15
+ /** Action keys such as `get:/orders`. */
16
+ publicOperations: ReadonlySet<string>;
17
+ deniedOperations: ReadonlySet<string>;
18
+ };
19
+
20
+ /** Header of the API key when the gateway does not say otherwise (same as the platform's OpenAPI export). */
21
+ export const DEFAULT_KEY_HEADER = "Ocp-Apim-Subscription-Key";
22
+
23
+ const METHODS = [
24
+ "get",
25
+ "put",
26
+ "post",
27
+ "delete",
28
+ "options",
29
+ "head",
30
+ "patch",
31
+ "trace",
32
+ ] as const;
33
+
34
+ type Json = Record<string, unknown>;
35
+
36
+ const isObject = (value: unknown): value is Json =>
37
+ typeof value === "object" && value !== null && !Array.isArray(value);
38
+
39
+ /** `GET /orders/` becomes `get:/orders`, the key the platform uses for an operation. */
40
+ export function actionKey(method: string, path: string): string {
41
+ const trimmed = path.trim();
42
+ const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
43
+ const normalized =
44
+ withSlash.length > 1 ? withSlash.replace(/\/+$/, "") || "/" : withSlash;
45
+ return `${method.trim().toLowerCase()}:${normalized}`;
46
+ }
47
+
48
+ /** A config entry like `GET /orders` as an action key, or undefined when it has no method and path. */
49
+ export function configActionKey(entry: string): string | undefined {
50
+ const match = /^\s*([A-Za-z]+)\s+(\S+)\s*$/.exec(entry);
51
+ return match?.[1] && match[2] ? actionKey(match[1], match[2]) : undefined;
52
+ }
53
+
54
+ /**
55
+ * What the platform's OpenAPI export is made with, from the config: a token is checked when `access.jwt` is on (the
56
+ * default when `auth` is set), a key unless `access.apiKey` is off.
57
+ */
58
+ export function referenceOptions(
59
+ gateway: ResolvedGateway,
60
+ gatewayBaseUrl: string,
61
+ keyHeaderName?: string,
62
+ ): ReferenceOptions {
63
+ const access = gateway.policies.access;
64
+ const jwt = access?.jwt ?? gateway.auth !== undefined;
65
+ const keys = (list?: string[]) =>
66
+ new Set(
67
+ (list ?? []).flatMap((entry) => {
68
+ const key = configActionKey(entry);
69
+ return key ? [key] : [];
70
+ }),
71
+ );
72
+ return {
73
+ gatewayBaseUrl,
74
+ originUrl: gateway.origin.url,
75
+ jwtRequired: jwt && gateway.auth !== undefined,
76
+ oidcMetadataUrl: jwt ? gateway.auth?.metadataUrl : undefined,
77
+ apiKeyRequired: access?.apiKey ?? true,
78
+ apiKeyHeader: keyHeaderName ?? access?.apiKeyHeader ?? DEFAULT_KEY_HEADER,
79
+ publicOperations: keys(gateway.policies.operations?.public),
80
+ deniedOperations: keys(gateway.policies.operations?.deny),
81
+ };
82
+ }
83
+
84
+ export class ReferenceSpecError extends Error {
85
+ constructor(message: string) {
86
+ super(message);
87
+ this.name = "ReferenceSpecError";
88
+ }
89
+ }
90
+
91
+ function operationsOf(paths: Json): [string, Json, (typeof METHODS)[number]][] {
92
+ return Object.entries(paths).flatMap(([path, item]) =>
93
+ isObject(item)
94
+ ? METHODS.filter((method) => isObject(item[method])).map(
95
+ (method) =>
96
+ [path, item, method] as [string, Json, (typeof METHODS)[number]],
97
+ )
98
+ : [],
99
+ );
100
+ }
101
+
102
+ /**
103
+ * The document the portal's API page shows. It follows what the platform exports for outside docs tools: servers
104
+ * point at the gateway, the security schemes say what the gateway really checks, denied operations are gone, public
105
+ * operations carry no security, and the origin address never shows (it is replaced by the gateway address).
106
+ */
107
+ export function buildReferenceSpec(
108
+ specText: string,
109
+ options: ReferenceOptions,
110
+ ): Json {
111
+ let root: unknown;
112
+ try {
113
+ root = parse(specText);
114
+ } catch {
115
+ throw new ReferenceSpecError("The OpenAPI file could not be read.");
116
+ }
117
+ if (!isObject(root) || typeof root.openapi !== "string")
118
+ throw new ReferenceSpecError("The OpenAPI file could not be read.");
119
+
120
+ const doc = structuredClone(root);
121
+ const paths = isObject(doc.paths) ? doc.paths : {};
122
+
123
+ // Denied operations are removed; a path with nothing left goes too.
124
+ for (const [path, item, method] of operationsOf(paths))
125
+ if (options.deniedOperations.has(actionKey(method, path)))
126
+ delete item[method];
127
+ for (const [path, item] of Object.entries(paths))
128
+ if (!isObject(item) || !METHODS.some((method) => method in item))
129
+ delete paths[path];
130
+
131
+ // Public operations need neither token nor key.
132
+ for (const [path, item, method] of operationsOf(paths))
133
+ if (options.publicOperations.has(actionKey(method, path)))
134
+ (item[method] as Json).security = [];
135
+
136
+ // Servers on paths and operations would point somewhere else, often at the origin.
137
+ doc.servers = [{ url: options.gatewayBaseUrl.replace(/\/+$/, "") }];
138
+ for (const item of Object.values(paths)) {
139
+ if (!isObject(item)) continue;
140
+ delete item.servers;
141
+ for (const method of METHODS) {
142
+ const operation = item[method];
143
+ if (isObject(operation)) delete operation.servers;
144
+ }
145
+ }
146
+
147
+ // The document describes the gateway, not the origin: the origin's own schemes are replaced.
148
+ const schemes: Json = {};
149
+ const requirement: Json = {};
150
+ if (options.jwtRequired) {
151
+ schemes.bearerAuth = {
152
+ type: "http",
153
+ scheme: "bearer",
154
+ bearerFormat: "JWT",
155
+ };
156
+ requirement.bearerAuth = [];
157
+ }
158
+ if (options.oidcMetadataUrl)
159
+ schemes.openIdAuth = {
160
+ type: "openIdConnect",
161
+ openIdConnectUrl: options.oidcMetadataUrl,
162
+ };
163
+ if (options.apiKeyRequired) {
164
+ schemes.apiKeyAuth = {
165
+ type: "apiKey",
166
+ in: "header",
167
+ name: options.apiKeyHeader,
168
+ };
169
+ requirement.apiKeyAuth = [];
170
+ }
171
+ const components = isObject(doc.components) ? doc.components : {};
172
+ if (Object.keys(schemes).length > 0) components.securitySchemes = schemes;
173
+ else delete components.securitySchemes;
174
+ if (Object.keys(components).length > 0) doc.components = components;
175
+ else delete doc.components;
176
+ // One requirement with both schemes means both are needed together.
177
+ doc.security = Object.keys(requirement).length > 0 ? [requirement] : [];
178
+ for (const [, item, method] of operationsOf(paths)) {
179
+ const operation = item[method] as Json;
180
+ if (Array.isArray(operation.security) && operation.security.length > 0)
181
+ delete operation.security;
182
+ }
183
+
184
+ return JSON.parse(hideOrigin(JSON.stringify(doc), options)) as Json;
185
+ }
186
+
187
+ /** The origin address must not show anywhere, not even in a description. */
188
+ function hideOrigin(text: string, options: ReferenceOptions): string {
189
+ const origin = options.originUrl?.trim().replace(/\/+$/, "");
190
+ if (!origin) return text;
191
+ const gateway = options.gatewayBaseUrl.trim().replace(/\/+$/, "");
192
+ // JSON.stringify leaves slashes as they are, so the plain address is what to look for.
193
+ const pattern = new RegExp(
194
+ origin.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"),
195
+ "gi",
196
+ );
197
+ return text.replace(pattern, () => gateway);
198
+ }
199
+
200
+ /** Reads the gateway's OpenAPI file and builds the reference document. Returns JSON text, one key order per input. */
201
+ export async function renderReferenceSpec(
202
+ gateway: ResolvedGateway,
203
+ gatewayBaseUrl: string,
204
+ keyHeaderName?: string,
205
+ ): Promise<string> {
206
+ const file = gateway.specs[0];
207
+ if (!file)
208
+ throw new ReferenceSpecError("gateway.specs lists no OpenAPI file.");
209
+ let text: string;
210
+ try {
211
+ text = await readFile(file, "utf8");
212
+ } catch {
213
+ throw new ReferenceSpecError(`Cannot read the OpenAPI file ${file}.`);
214
+ }
215
+ const spec = buildReferenceSpec(
216
+ text,
217
+ referenceOptions(gateway, gatewayBaseUrl, keyHeaderName),
218
+ );
219
+ return `${JSON.stringify(spec, null, 2)}\n`;
220
+ }
@@ -0,0 +1,123 @@
1
+ import type { ResolvedGateway } from "./resolve.js";
2
+
3
+ /**
4
+ * The body of `PUT /gateway/config`: the wanted state of the gateway (Gateway design doc, 8.9 and 8.12).
5
+ * Only names of secrets are in it, never values.
6
+ */
7
+ export type GatewayConfigBody = {
8
+ provider: string;
9
+ origin: {
10
+ mode: "managed" | "proxy";
11
+ url?: string;
12
+ healthPath?: string;
13
+ timeoutSeconds?: number;
14
+ auth?: { type: string; name?: string; value?: string };
15
+ };
16
+ auth?: {
17
+ mode: string;
18
+ metadataUrl?: string;
19
+ issuers?: string[];
20
+ audiences?: string[];
21
+ };
22
+ export?: { access?: string };
23
+ /** Sites of this project that call the gateway from a browser. The platform adds them to the allowed origins. */
24
+ corsDerived?: DerivedCorsSite[];
25
+ policies?: {
26
+ access?: {
27
+ jwt?: boolean;
28
+ apiKey?: boolean;
29
+ apiKeyHeader?: string;
30
+ bindKeyToToken?: boolean;
31
+ };
32
+ rateLimit?: { calls: number; periodSeconds: number; scope?: string };
33
+ quota?: { calls: number; periodDays: number; scope?: string };
34
+ cors?: string[];
35
+ operations?: { public?: string[]; deny?: string[] };
36
+ };
37
+ };
38
+
39
+ export type DerivedCorsSite = { origin: string; source: "portal" | "docs" };
40
+
41
+ /**
42
+ * The browser origins of this project's own sites: the portal (from `services.json`) and an external docs site
43
+ * (from the config). Only the origin is kept, never a path. Sorted, so the same sites always give the same body.
44
+ */
45
+ export function derivedCorsSites(sites: {
46
+ portalUrl?: string;
47
+ docsUrl?: string;
48
+ }): DerivedCorsSite[] {
49
+ const originOf = (url?: string) => {
50
+ if (!url) return undefined;
51
+ try {
52
+ const { protocol, origin } = new URL(url);
53
+ return protocol === "https:" ? origin : undefined;
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ };
58
+ const found = [
59
+ { origin: originOf(sites.docsUrl), source: "docs" as const },
60
+ { origin: originOf(sites.portalUrl), source: "portal" as const },
61
+ ].flatMap((site) => (site.origin ? [{ ...site, origin: site.origin }] : []));
62
+ const seen = new Set<string>();
63
+ return found
64
+ .filter((site) => !seen.has(site.origin) && seen.add(site.origin))
65
+ .sort((a, b) => (a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0));
66
+ }
67
+
68
+ const defined = <T extends Record<string, unknown>>(value: T): T =>
69
+ Object.fromEntries(
70
+ Object.entries(value).filter(([, item]) => item !== undefined),
71
+ ) as T;
72
+
73
+ /**
74
+ * Turns the resolved `gateway` section into the request body. The spec files are not part of it: they are uploaded
75
+ * with `PUT /gateway/spec`. Fields the platform fills with defaults are left out when the config does not set them,
76
+ * so the same config always gives the same body (and the same hash).
77
+ */
78
+ export function gatewayConfigBody(
79
+ gateway: ResolvedGateway,
80
+ derivedCors: DerivedCorsSite[] = [],
81
+ ): GatewayConfigBody {
82
+ const { origin, auth, policies } = gateway;
83
+ const body: GatewayConfigBody = {
84
+ provider: gateway.provider,
85
+ origin: defined({
86
+ mode: origin.mode,
87
+ url: origin.url,
88
+ healthPath: origin.healthPath,
89
+ timeoutSeconds: origin.timeoutSeconds,
90
+ auth: origin.auth ? defined({ ...origin.auth }) : undefined,
91
+ }),
92
+ };
93
+ if (auth) body.auth = defined({ ...auth });
94
+ if (gateway.export) body.export = defined({ ...gateway.export });
95
+ if (derivedCors.length > 0) body.corsDerived = derivedCors;
96
+ if (Object.keys(policies).length > 0) {
97
+ body.policies = defined({
98
+ access: policies.access ? defined({ ...policies.access }) : undefined,
99
+ rateLimit: policies.rateLimit
100
+ ? defined({ ...policies.rateLimit })
101
+ : undefined,
102
+ quota: policies.quota ? defined({ ...policies.quota }) : undefined,
103
+ cors: policies.cors,
104
+ operations: policies.operations
105
+ ? defined({ ...policies.operations })
106
+ : undefined,
107
+ });
108
+ }
109
+ return body;
110
+ }
111
+
112
+ /** Stable JSON text of a value: object keys sorted, so equal content gives equal text. */
113
+ export function stableJson(value: unknown): string {
114
+ return JSON.stringify(value, (_key, item) =>
115
+ item && typeof item === "object" && !Array.isArray(item)
116
+ ? Object.fromEntries(
117
+ Object.entries(item as Record<string, unknown>).sort(([a], [b]) =>
118
+ a < b ? -1 : a > b ? 1 : 0,
119
+ ),
120
+ )
121
+ : item,
122
+ );
123
+ }
@@ -54,15 +54,32 @@ export {
54
54
  missingManifestServices,
55
55
  parseManifest,
56
56
  readManifest,
57
+ removeServicesFromManifest,
57
58
  requiredServices,
58
59
  SUPPORTED_MANIFEST_SCHEMA,
59
60
  type ServiceManifest,
60
61
  serviceReady,
61
62
  stubManifest,
63
+ withGatewayEntry,
62
64
  withPortalEntry,
63
65
  writeManifest,
64
66
  } from "./manifest.js";
65
67
  export { MANIFEST_FILE, manifestPathFor } from "./paths.js";
68
+ export {
69
+ buildReferenceSpec,
70
+ DEFAULT_KEY_HEADER,
71
+ type ReferenceOptions,
72
+ ReferenceSpecError,
73
+ referenceOptions,
74
+ renderReferenceSpec,
75
+ } from "./gateway-reference.js";
76
+ export {
77
+ type DerivedCorsSite,
78
+ derivedCorsSites,
79
+ type GatewayConfigBody,
80
+ gatewayConfigBody,
81
+ stableJson,
82
+ } from "./gateway-request.js";
66
83
  export {
67
84
  consoleLinks,
68
85
  planPortal,
@@ -84,6 +101,7 @@ export {
84
101
  planAddService,
85
102
  planRemoveService,
86
103
  SERVICES,
104
+ clearProjectId,
87
105
  ServiceEditError,
88
106
  setProjectId,
89
107
  type ServiceEdit,
@@ -91,6 +109,7 @@ export {
91
109
  } from "./services.js";
92
110
  export { checkDependencies } from "./rules.js";
93
111
  export {
112
+ clearServices,
94
113
  emptyLedger,
95
114
  type Ledger,
96
115
  LEDGER_VERSION,
@@ -217,3 +217,19 @@ export async function runPipeline(
217
217
  }
218
218
  return outcomes;
219
219
  }
220
+
221
+ /** Forgets the recorded steps of services that no longer exist on the platform. Other services keep their entries. */
222
+ export async function clearServices(
223
+ state: Pick<StatePaths, "file">,
224
+ services: readonly string[],
225
+ ): Promise<void> {
226
+ const ledger = await readLedger(state);
227
+ const remaining = Object.fromEntries(
228
+ Object.entries(ledger.services).filter(
229
+ ([name]) => !services.includes(name),
230
+ ),
231
+ );
232
+ if (Object.keys(remaining).length === Object.keys(ledger.services).length)
233
+ return;
234
+ await writeLedger(state, { ...ledger, services: remaining });
235
+ }
@@ -271,6 +271,54 @@ export function withPortalEntry(
271
271
  };
272
272
  }
273
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
+
274
322
  /** Replaces `services.json` atomically. Public values only: this file ships inside the portal. */
275
323
  export async function writeManifest(
276
324
  state: Pick<StatePaths, "dir">,
@@ -291,3 +339,25 @@ export async function writeManifest(
291
339
  throw error;
292
340
  }
293
341
  }
342
+
343
+ /**
344
+ * Drops the entries of services that were torn down. The file is deleted when no service is left, because a
345
+ * manifest with no services says nothing and would only look like a deployed project.
346
+ */
347
+ export async function removeServicesFromManifest(
348
+ state: Pick<StatePaths, "dir">,
349
+ names: readonly ManifestServiceName[],
350
+ ): Promise<void> {
351
+ const existing = await readManifest(state);
352
+ if (!existing) return;
353
+ const services = Object.fromEntries(
354
+ Object.entries(existing.services).filter(
355
+ ([name]) => !(names as readonly string[]).includes(name),
356
+ ),
357
+ );
358
+ if (Object.keys(services).length === 0) {
359
+ await rm(manifestPathFor(state), { force: true });
360
+ return;
361
+ }
362
+ await writeManifest(state, { ...existing, services });
363
+ }
@@ -35,6 +35,7 @@ export type ResolvedGateway = {
35
35
  /** Absolute paths. */
36
36
  specs: string[];
37
37
  auth?: NonNullable<ConfigV2["gateway"]>["auth"];
38
+ export?: NonNullable<ConfigV2["gateway"]>["export"];
38
39
  policies: NonNullable<NonNullable<ConfigV2["gateway"]>["policies"]>;
39
40
  };
40
41
 
@@ -149,6 +150,7 @@ export function resolveConfigV2(
149
150
  },
150
151
  specs: gateway.specs.map((spec) => path.resolve(configDir, spec)),
151
152
  auth: gateway.auth,
153
+ export: gateway.export,
152
154
  policies: gateway.policies ?? {},
153
155
  };
154
156
  }