@lukoweb/apitogo 0.2.2 → 0.2.10
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/dist/cli/cli.js +2059 -476
- package/dist/declarations/cli/platform/client/platform-api.d.ts +114 -0
- package/package.json +1 -1
- package/src/config/v2/gateway-request.ts +123 -0
- package/src/config/v2/index.ts +11 -0
- package/src/config/v2/ledger.ts +16 -0
- package/src/config/v2/manifest.ts +70 -0
- package/src/config/v2/resolve.ts +2 -0
- package/src/config/v2/rules.ts +64 -0
- package/src/config/v2/schema.ts +34 -0
- package/src/config/v2/services.ts +17 -0
|
@@ -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
|
@@ -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
|
+
}
|
package/src/config/v2/index.ts
CHANGED
|
@@ -54,15 +54,24 @@ 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
|
+
type DerivedCorsSite,
|
|
70
|
+
derivedCorsSites,
|
|
71
|
+
type GatewayConfigBody,
|
|
72
|
+
gatewayConfigBody,
|
|
73
|
+
stableJson,
|
|
74
|
+
} from "./gateway-request.js";
|
|
66
75
|
export {
|
|
67
76
|
consoleLinks,
|
|
68
77
|
planPortal,
|
|
@@ -84,6 +93,7 @@ export {
|
|
|
84
93
|
planAddService,
|
|
85
94
|
planRemoveService,
|
|
86
95
|
SERVICES,
|
|
96
|
+
clearProjectId,
|
|
87
97
|
ServiceEditError,
|
|
88
98
|
setProjectId,
|
|
89
99
|
type ServiceEdit,
|
|
@@ -91,6 +101,7 @@ export {
|
|
|
91
101
|
} from "./services.js";
|
|
92
102
|
export { checkDependencies } from "./rules.js";
|
|
93
103
|
export {
|
|
104
|
+
clearServices,
|
|
94
105
|
emptyLedger,
|
|
95
106
|
type Ledger,
|
|
96
107
|
LEDGER_VERSION,
|
package/src/config/v2/ledger.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/config/v2/resolve.ts
CHANGED
|
@@ -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
|
}
|
package/src/config/v2/rules.ts
CHANGED
|
@@ -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
|
|
package/src/config/v2/schema.ts
CHANGED
|
@@ -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
|
+
}
|