@lukoweb/apitogo 0.2.1 → 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.
@@ -318,6 +318,117 @@ 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
+ }
420
+ export interface ProjectSecretDto {
421
+ name: string;
422
+ updatedAtUtc: string;
423
+ }
424
+ export interface ProjectSecretListResponse {
425
+ secrets: ProjectSecretDto[];
426
+ }
427
+ export interface SetProjectSecretResponse {
428
+ name: string;
429
+ created: boolean;
430
+ updatedAtUtc: string;
431
+ }
321
432
  export interface StaticWebSiteDeploymentDto {
322
433
  projectId: string;
323
434
  environment: string;
@@ -388,10 +499,28 @@ export declare class PlatformApiClient {
388
499
  listProvisioningJobs(projectId: string): Promise<ProvisioningJobListResponse>;
389
500
  getDeploymentToken(projectId: string, regenerate?: boolean): Promise<DeploymentTokenResponse>;
390
501
  uploadStaticWebDeployment(projectId: string, archivePath: string, env?: string): Promise<StaticWebSiteDeploymentDto>;
502
+ listSecrets(projectId: string): Promise<ProjectSecretListResponse>;
503
+ setSecret(projectId: string, name: string, value: string): Promise<SetProjectSecretResponse>;
504
+ deleteSecret(projectId: string, name: string): Promise<void>;
391
505
  getCustomDomain(projectId: string): Promise<UserCustomDomainResponse>;
392
506
  setCustomDomain(projectId: string, body: SetUserCustomDomainRequest): Promise<UserCustomDomainResponse>;
393
507
  deleteCustomDomain(projectId: string): Promise<void>;
394
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>;
395
524
  getDevPortalBackend(projectId: string): Promise<DevPortalBackendDto | null>;
396
525
  getDevPortalSetupStatus(projectId: string): Promise<DevPortalSetupStatusDto>;
397
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.1",
3
+ "version": "0.2.10",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -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,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,
@@ -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
  }
@@ -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
 
@@ -607,7 +671,7 @@ function secretRules(
607
671
  if (refs.length === 0) return;
608
672
  if (!context.knownSecrets) {
609
673
  skipped.push(
610
- `secret references (${new Set(refs.map((r) => r.name)).size} found) were not checked: the project's secrets cannot be listed yet`,
674
+ `secret references (${new Set(refs.map((r) => r.name)).size} found) were not checked: the project's secrets were not available (offline, not logged in, or this folder is not linked to a project)`,
611
675
  );
612
676
  return;
613
677
  }
@@ -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
+ }