@lukoweb/apitogo 0.2.38 → 0.2.40

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 CHANGED
@@ -6596,7 +6596,7 @@ function dependencyRules(config2, resolved, out) {
6596
6596
  const identityOn = resolved.identity?.enabled === true;
6597
6597
  const oidcGateway = gatewayOn && resolved.gateway?.auth?.mode === "oidc";
6598
6598
  const hasIdentitySource = identityOn || oidcGateway;
6599
- if (identityOn && resolved.portal?.enabled !== true && !gatewayOn) {
6599
+ if (identityOn && resolved.portal?.enabled !== true && !gatewayOn && !resolved.project.id) {
6600
6600
  out.error({
6601
6601
  code: "NOTHING_TO_HOST",
6602
6602
  path: "identity",
@@ -7932,7 +7932,11 @@ function planPortal(profile, manifest) {
7932
7932
  plan2.plansPlugin = {
7933
7933
  apiBaseUrl: plans.apiBaseUrl,
7934
7934
  keysApiBaseUrl: plans.keysApiBaseUrl,
7935
- billing: plans.billing
7935
+ billing: plans.billing,
7936
+ links: {
7937
+ ...profile.modules.docs ? { docs: "/introduction" } : profile.docs.url ? { docs: profile.docs.url } : {},
7938
+ ...plan2.apiReference ? { api: `/${API_REFERENCE_PATH}` } : {}
7939
+ }
7936
7940
  };
7937
7941
  }
7938
7942
  return plan2;
@@ -8634,7 +8638,7 @@ async function buildPlans(context) {
8634
8638
  const { options, dir, resolved } = context;
8635
8639
  const o = options.plans ?? {};
8636
8640
  const template = o.template ?? (options.template === "blank" ? "none" : options.template === "starter" ? "free" : "free");
8637
- const billing = o.billing ?? "stripe";
8641
+ const billing = o.billing ?? (template === "tiered" ? "stripe" : "none");
8638
8642
  if (template === "tiered" && billing === "none") {
8639
8643
  throw new ServiceEditError(
8640
8644
  "The tiered template has paid plans, which need a billing provider.",
@@ -9731,6 +9735,16 @@ var PlatformApiClient = class {
9731
9735
  `/api/v1/projects/${projectId}/gateway/plans/${planId}`
9732
9736
  );
9733
9737
  }
9738
+ /**
9739
+ * For test runs only: puts the project's plans backend back to empty (accounts, subscriptions, keys, cached catalogs).
9740
+ * Answers 404 unless the platform has the test tools on and the project is a `tester-` project.
9741
+ */
9742
+ resetDevPortalTestData(projectId) {
9743
+ return this.request(
9744
+ "POST",
9745
+ `/api/v1/projects/${projectId}/dev-portal/test-reset`
9746
+ );
9747
+ }
9734
9748
  /**
9735
9749
  * For test runs only: a test user and a one-time sign-in link for it. The platform answers 404 unless it allows test
9736
9750
  * tools and the project is a `tester-` project.
@@ -10073,7 +10087,7 @@ import {
10073
10087
  // package.json
10074
10088
  var package_default = {
10075
10089
  name: "@lukoweb/apitogo",
10076
- version: "0.2.38",
10090
+ version: "0.2.40",
10077
10091
  type: "module",
10078
10092
  sideEffects: [
10079
10093
  "**/*.css",
@@ -15095,6 +15109,14 @@ function capabilityIssues(gateway, answer) {
15095
15109
  );
15096
15110
  return found;
15097
15111
  }
15112
+ function pendingIdentityNote(item, state) {
15113
+ if (item.code !== "GATEWAY_AUTH_INCOMPLETE" || !state.identityEnabled || state.identityDeployed)
15114
+ return void 0;
15115
+ return {
15116
+ ...item,
15117
+ message: `${item.message} The identity is made by the first deploy, before the gateway, so this is expected until then.`
15118
+ };
15119
+ }
15098
15120
  async function onlineGatewayRules(gateway, projectId, api, derivedCors = []) {
15099
15121
  let spec;
15100
15122
  const specFile = gateway.specs[0];
@@ -15413,7 +15435,12 @@ async function addGatewayOnlineRules(report, resolved, args, deps) {
15413
15435
  const key = `${item.code}|${item.path}`;
15414
15436
  if (seen.has(key)) continue;
15415
15437
  seen.add(key);
15416
- report.errors.push(item);
15438
+ const pending = pendingIdentityNote(item, {
15439
+ identityEnabled: resolved.identity?.enabled === true,
15440
+ identityDeployed: manifest?.services.identity !== void 0
15441
+ });
15442
+ if (pending) report.warnings.push(pending);
15443
+ else report.errors.push(item);
15417
15444
  }
15418
15445
  return true;
15419
15446
  }
@@ -26983,6 +27010,18 @@ function describePlatformProblem(error) {
26983
27010
  )
26984
27011
  ] : [];
26985
27012
  }
27013
+ async function withServerRetry(request, sleep5, waitsMs = [5e3, 15e3]) {
27014
+ for (const wait of waitsMs) {
27015
+ try {
27016
+ return await request();
27017
+ } catch (error) {
27018
+ if (!(error instanceof PlatformApiError) || error.status < 500)
27019
+ throw error;
27020
+ await sleep5(wait);
27021
+ }
27022
+ }
27023
+ return request();
27024
+ }
26986
27025
  function rejected(what, error) {
26987
27026
  if (error instanceof PlatformApiError && error.status === 400) {
26988
27027
  const lines = describePlatformProblem(error);
@@ -27002,7 +27041,7 @@ async function deployGateway(resolved, projectId, deps, options = {}) {
27002
27041
  const client = deps.client;
27003
27042
  const sleep5 = deps.sleep ?? defaultSleep;
27004
27043
  const now = deps.now ?? Date.now;
27005
- const timeoutMs = deps.reconcileTimeoutMs ?? 18e4;
27044
+ const timeoutMs = deps.reconcileTimeoutMs ?? 42e4;
27006
27045
  const backendTimeoutMs = deps.backendTimeoutMs ?? 20 * 6e4;
27007
27046
  const intervalMs = deps.pollIntervalMs ?? 2e3;
27008
27047
  const backendStep = {
@@ -27070,7 +27109,10 @@ async function deployGateway(resolved, projectId, deps, options = {}) {
27070
27109
  hash: () => specHash,
27071
27110
  run: async () => {
27072
27111
  try {
27073
- const uploaded = await client.putGatewaySpec(projectId, spec);
27112
+ const uploaded = await withServerRetry(
27113
+ () => client.putGatewaySpec(projectId, spec),
27114
+ sleep5
27115
+ );
27074
27116
  return {
27075
27117
  specSha256: uploaded.sha256,
27076
27118
  operationCount: String(uploaded.operationCount)
@@ -27086,7 +27128,10 @@ async function deployGateway(resolved, projectId, deps, options = {}) {
27086
27128
  hash: () => configHash,
27087
27129
  run: async () => {
27088
27130
  try {
27089
- const accepted = await client.putGatewayConfig(projectId, body);
27131
+ const accepted = await withServerRetry(
27132
+ () => client.putGatewayConfig(projectId, body),
27133
+ sleep5
27134
+ );
27090
27135
  return {
27091
27136
  reconciliationId: accepted.reconciliationId,
27092
27137
  desiredHash: accepted.desiredHash
@@ -27095,13 +27140,17 @@ async function deployGateway(resolved, projectId, deps, options = {}) {
27095
27140
  return rejected("gateway config", error);
27096
27141
  }
27097
27142
  },
27098
- // A reconciliation the platform gave up on is not a result worth keeping.
27143
+ // A reconciliation the platform gave up on is not a result worth keeping, and neither is one for a gateway that was
27144
+ // removed since (a reset, a teardown, or the web app): the records here would say "deployed" for a gateway that is gone.
27099
27145
  stillValid: async () => {
27100
27146
  const ticket = await ticketOf();
27101
27147
  if (!ticket) return false;
27102
27148
  try {
27103
27149
  const state = await client.getGatewayReconciliation(projectId, ticket);
27104
- return state.state !== "Failed" && state.state !== "Superseded";
27150
+ if (state.state === "Failed" || state.state === "Superseded")
27151
+ return false;
27152
+ const gateway2 = await client.getGatewayStatus(projectId);
27153
+ return gateway2.status !== "Released";
27105
27154
  } catch {
27106
27155
  return false;
27107
27156
  }
@@ -27132,7 +27181,7 @@ async function deployGateway(resolved, projectId, deps, options = {}) {
27132
27181
  throw new GatewayDeployError(
27133
27182
  "GATEWAY_RECONCILE_TIMEOUT",
27134
27183
  `The platform did not finish within ${Math.round(timeoutMs / 1e3)} seconds (state: ${state.state}).`,
27135
- "Run deploy again to keep waiting. If it stays Pending, the platform worker that applies gateway configs may not be running yet."
27184
+ "The gateway is still being made: run deploy again to keep waiting (a gateway made just after a reset takes the longest). If it stays Pending for many minutes, the platform worker that applies gateway configs may not be running."
27136
27185
  );
27137
27186
  await sleep5(intervalMs);
27138
27187
  }
@@ -27263,6 +27312,14 @@ function referencedSecretNames(connections) {
27263
27312
  )
27264
27313
  ];
27265
27314
  }
27315
+ function identityProviderSwitch(resolved, known) {
27316
+ if (!known?.provider || !resolved.identity) return void 0;
27317
+ const to = resolved.identity.provider ?? "apitogo";
27318
+ return known.provider === to ? void 0 : { from: known.provider, to };
27319
+ }
27320
+ function identityProviderSwitchWarning(from, to) {
27321
+ return `warning: the identity provider changes from ${from} to ${to}. People are known by issuer and subject, so the users, subscriptions and keys made with ${from} are not carried over to ${to}.`;
27322
+ }
27266
27323
  var PORTAL_CALLBACK_PATH = "/oauth/callback";
27267
27324
  var defaultSleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
27268
27325
  function identityNotDeployableReason(resolved) {
@@ -27941,6 +27998,8 @@ async function planIdentityService(resolved, ledger, manifest) {
27941
27998
  details: []
27942
27999
  };
27943
28000
  const known = manifest?.services.identity;
28001
+ const switched = identityProviderSwitch(resolved, known);
28002
+ const switchNote = switched ? [identityProviderSwitchWarning(switched.from, switched.to)] : [];
27944
28003
  if (identity.provider === "oidc") {
27945
28004
  const same = known?.provider === "oidc" && known.issuer === identity.issuer?.replace(/\/+$/, "") && known.clientId === identity.clientId;
27946
28005
  return {
@@ -27948,6 +28007,7 @@ async function planIdentityService(resolved, ledger, manifest) {
27948
28007
  action: same ? "skip" : "deploy",
27949
28008
  reason: same ? "deploy skipped: services.json already records your sign-in provider" : "will record your own sign-in provider in services.json (nothing is made on the platform)",
27950
28009
  details: [
28010
+ ...switchNote,
27951
28011
  `provider: oidc ${identity.issuer}`,
27952
28012
  `client id: ${identity.clientId}`
27953
28013
  ]
@@ -27959,6 +28019,7 @@ async function planIdentityService(resolved, ledger, manifest) {
27959
28019
  const body = portalUrl ? identityRequestBody(resolved, portalUrl) : recorded ? identityBodyForRedirect(resolved, recorded) : void 0;
27960
28020
  const hash2 = projectId && body ? identityInputsHash(projectId, body) : void 0;
27961
28021
  const details = [
28022
+ ...switchNote,
27962
28023
  "provider: apitogo (one issuer for this project)",
27963
28024
  `sign-up with email and password: ${identity.native ? "on" : "off"}`,
27964
28025
  portalUrl ? `sign-in redirect: ${portalUrl.replace(/\/+$/, "")}${PORTAL_CALLBACK_PATH}` : recorded ? `sign-in redirect: ${recorded} (sent by the last deploy)` : "sign-in redirect: the portal address is not known locally; deploy takes it from the project",
@@ -28566,6 +28627,20 @@ async function gatewayStatus(resolved, ledger, manifest, remote) {
28566
28627
  if (failed) state = "deploy-failed";
28567
28628
  return { service: "gateway", state, details: [...details, ...local] };
28568
28629
  }
28630
+ async function hostingLine(resolved, manifest, remote) {
28631
+ const projectId = resolved.project.id ?? manifest?.project.id;
28632
+ if (!remote || !projectId) return [];
28633
+ try {
28634
+ const address = await remote(projectId);
28635
+ return address ? [`hosting: ready (${address})`] : [
28636
+ "hosting: not ready yet (the platform is still preparing it; wait a few minutes, then run `apitogo deploy`)"
28637
+ ];
28638
+ } catch (error) {
28639
+ return [
28640
+ `hosting: not reachable (${error instanceof Error ? error.message : String(error)})`
28641
+ ];
28642
+ }
28643
+ }
28569
28644
  async function wantedSkus(resolved) {
28570
28645
  if (!resolved.plans) return void 0;
28571
28646
  try {
@@ -28608,7 +28683,8 @@ async function statusV2(dir, only, options = {}) {
28608
28683
  ...state === "built" ? [
28609
28684
  `output: ${path82.relative(resolved.configDir, resolved.state.buildDir)}`
28610
28685
  ] : [],
28611
- ...url2 ? [`url: ${url2}`] : ["url: not known (portal is not deployed yet)"]
28686
+ ...url2 ? [`url: ${url2}`] : ["url: not known (portal is not deployed yet)"],
28687
+ ...await hostingLine(resolved, manifest, options.hostingRemote)
28612
28688
  ]
28613
28689
  });
28614
28690
  continue;
@@ -28886,6 +28962,16 @@ async function deployIdentityService(options, deps, projectId, named) {
28886
28962
  );
28887
28963
  return { service: "identity", status: "skipped", reason: why, steps: [] };
28888
28964
  }
28965
+ const switched = identityProviderSwitch(
28966
+ resolved,
28967
+ (await readManifest(resolved.state))?.services.identity
28968
+ );
28969
+ if (switched && options.yes !== true)
28970
+ throw new DeployError(
28971
+ "CONFIRMATION_REQUIRED",
28972
+ identityProviderSwitchWarning(switched.from, switched.to),
28973
+ "`apitogo plan` shows it too. Run `apitogo deploy --yes` to go on."
28974
+ );
28889
28975
  const client = deps.client;
28890
28976
  if (resolved.identity?.provider !== "oidc" && (!client.putProjectIdentity || !client.getProjectIdentity || !client.getProject))
28891
28977
  throw new Error("The platform client has no identity calls.");
@@ -28937,7 +29023,26 @@ async function deployPlansService(options, deps, projectId, named) {
28937
29023
  ...result.notes ? { notes: result.notes } : {}
28938
29024
  };
28939
29025
  }
29026
+ async function recordPortalAddressBeforeBuild(dir, deps, projectId) {
29027
+ const { resolved } = loadConfigV2(dir);
29028
+ if (!resolved.portal?.enabled) return;
29029
+ const manifest = await readManifest(resolved.state);
29030
+ if (manifest?.services.portal?.url) return;
29031
+ const url2 = resolveSwaUrl(await deps.client.getProject(projectId));
29032
+ if (!url2) return;
29033
+ const profile = portalProfile(resolved);
29034
+ await writeManifest(
29035
+ resolved.state,
29036
+ withPortalEntry(manifest, {
29037
+ projectId,
29038
+ projectName: resolved.project.name,
29039
+ url: url2,
29040
+ docs: { provider: profile.docs.provider, url: profile.docs.url }
29041
+ })
29042
+ );
29043
+ }
28940
29044
  async function deployPortal(options, deps, projectId) {
29045
+ await recordPortalAddressBeforeBuild(options.dir, deps, projectId);
28941
29046
  const { resolved, root, prepared } = await preparePortalWorkspace(
28942
29047
  options.dir,
28943
29048
  "production"
@@ -29021,6 +29126,7 @@ async function deployProject(argv) {
29021
29126
  projectId: argv.projectId,
29022
29127
  env: argv.env,
29023
29128
  force: argv.force === true,
29129
+ yes: argv.yes === true,
29024
29130
  jsonOnly: json
29025
29131
  },
29026
29132
  {
@@ -29083,6 +29189,11 @@ var deploy_default = {
29083
29189
  type: "boolean",
29084
29190
  describe: "Force redeploy of selected resources even when healthy",
29085
29191
  default: false
29192
+ }).option("yes", {
29193
+ alias: "y",
29194
+ type: "boolean",
29195
+ describe: "Config version 2: confirm a change that cannot be undone, such as switching the identity provider",
29196
+ default: false
29086
29197
  }).option("resume-from", {
29087
29198
  type: "string",
29088
29199
  describe: "Resume publish ledger from step id"
@@ -29523,10 +29634,19 @@ function plansRemote() {
29523
29634
  return void 0;
29524
29635
  }
29525
29636
  }
29637
+ function hostingRemote() {
29638
+ try {
29639
+ const client = new PlatformApiClient(loadConfig());
29640
+ return async (projectId) => resolveSwaUrl(await client.getProject(projectId));
29641
+ } catch {
29642
+ return void 0;
29643
+ }
29644
+ }
29526
29645
  var statusOfProject = (argv) => run2(
29527
29646
  "status",
29528
29647
  argv,
29529
29648
  (dir, only) => statusV2(dir, only, {
29649
+ hostingRemote: hostingRemote(),
29530
29650
  gatewayRemote: gatewayRemote(),
29531
29651
  identityRemote: identityRemote(),
29532
29652
  identityConnectionsRemote: identityConnectionsRemote(),
@@ -31315,10 +31435,11 @@ async function resetProjectV2(options, deps) {
31315
31435
  "Resetting removes the plans, the identity with every user, and the gateway of the project.",
31316
31436
  "Pass --yes to go on."
31317
31437
  );
31438
+ const sleep5 = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
31318
31439
  const steps = [];
31319
31440
  for (const service of STEPS) {
31320
31441
  try {
31321
- const result = await teardownV2(
31442
+ const removeOnce = () => teardownV2(
31322
31443
  {
31323
31444
  dir: options.dir,
31324
31445
  projectId,
@@ -31328,6 +31449,12 @@ async function resetProjectV2(options, deps) {
31328
31449
  },
31329
31450
  deps
31330
31451
  );
31452
+ const result = await removeOnce().catch(async (error) => {
31453
+ if (!(error instanceof PlatformApiError) || error.status < 500)
31454
+ throw error;
31455
+ await sleep5(5e3);
31456
+ return removeOnce();
31457
+ });
31331
31458
  if (result.status === "pending") {
31332
31459
  steps.push({ service, outcome: "pending", message: result.message });
31333
31460
  return {
@@ -31336,6 +31463,7 @@ async function resetProjectV2(options, deps) {
31336
31463
  status: "pending",
31337
31464
  steps,
31338
31465
  cleared: [],
31466
+ notes: [],
31339
31467
  message: `The ${service} is still being removed by the platform. Run the reset again in a minute.`
31340
31468
  };
31341
31469
  }
@@ -31348,6 +31476,22 @@ async function resetProjectV2(options, deps) {
31348
31476
  throw error;
31349
31477
  }
31350
31478
  }
31479
+ let backendNote;
31480
+ if (deps.client.resetDevPortalTestData) {
31481
+ const emptyOnce = async () => {
31482
+ try {
31483
+ return (await deps.client.resetDevPortalTestData?.(projectId))?.plansBackend ?? "no-answer";
31484
+ } catch (error) {
31485
+ return `failed-${error instanceof Error ? error.message : String(error)}`;
31486
+ }
31487
+ };
31488
+ let outcome = await emptyOnce();
31489
+ if (outcome.startsWith("failed") || /^refused-5/.test(outcome)) {
31490
+ await sleep5(15e3);
31491
+ outcome = await emptyOnce();
31492
+ }
31493
+ backendNote = outcome === "notified" ? "plans backend: emptied" : outcome === "no-backend" ? "plans backend: nothing to empty yet (the project has none)" : `plans backend: not emptied (${outcome})`;
31494
+ }
31351
31495
  const cleared = [];
31352
31496
  const ledger = await readLedger(resolved.state);
31353
31497
  const recorded = DEPLOY_ORDER.filter((service) => ledger.services[service]);
@@ -31363,6 +31507,7 @@ async function resetProjectV2(options, deps) {
31363
31507
  status: "reset",
31364
31508
  steps,
31365
31509
  cleared,
31510
+ notes: backendNote ? [backendNote] : [],
31366
31511
  message: "The project is reset: it is linked and its hosting is kept. Run `apitogo deploy` to build on it again."
31367
31512
  };
31368
31513
  }
@@ -31392,7 +31537,10 @@ async function resetProject(argv) {
31392
31537
  return;
31393
31538
  }
31394
31539
  for (const step2 of result.steps)
31395
- console.log(`${step2.service}: ${step2.outcome}`);
31540
+ console.log(
31541
+ `${step2.service}: ${step2.outcome}${step2.outcome === "skipped" ? " (nothing was deployed)" : ""}`
31542
+ );
31543
+ for (const note of result.notes) console.log(note);
31396
31544
  if (result.cleared.length > 0)
31397
31545
  console.log(`Cleared locally: ${result.cleared.join(", ")}.`);
31398
31546
  console.log(result.message);
@@ -599,6 +599,9 @@ export declare class PlatformApiClient {
599
599
  getGatewayPlan(projectId: string, planId: string): Promise<GatewayPlanDto>;
600
600
  updateGatewayPlan(projectId: string, planId: string, body: UpdateGatewayPlanRequest): Promise<GatewayPlanDto>;
601
601
  deleteGatewayPlan(projectId: string, planId: string): Promise<void>;
602
+ resetDevPortalTestData(projectId: string): Promise<{
603
+ plansBackend: string;
604
+ }>;
602
605
  createTestSignIn(projectId: string, body: {
603
606
  email: string;
604
607
  returnUrl?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lukoweb/apitogo",
3
- "version": "0.2.38",
3
+ "version": "0.2.40",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -73,8 +73,14 @@ function dependencyRules(
73
73
  const oidcGateway = gatewayOn && resolved.gateway?.auth?.mode === "oidc";
74
74
  const hasIdentitySource = identityOn || oidcGateway;
75
75
 
76
- // `apitogo project create` hosts services through the portal or the gateway, so identity alone has nowhere to run.
77
- if (identityOn && resolved.portal?.enabled !== true && !gatewayOn) {
76
+ // `apitogo project create` hosts services through the portal or the gateway, so identity alone has nowhere to run. A folder
77
+ // that is already linked to a project has its hosting, and `deploy` puts the identity there (found in scenario F).
78
+ if (
79
+ identityOn &&
80
+ resolved.portal?.enabled !== true &&
81
+ !gatewayOn &&
82
+ !resolved.project.id
83
+ ) {
78
84
  out.error({
79
85
  code: "NOTHING_TO_HOST",
80
86
  path: "identity",
@@ -313,10 +313,10 @@ async function buildPortal(
313
313
  if (content.length === 0) {
314
314
  let template = options.template;
315
315
  if (!template && options.ask)
316
- template = await options.ask.choose("Content of the new site", [
316
+ template = (await options.ask.choose("Content of the new site", [
317
317
  { value: "starter", label: "A starter page I can edit" },
318
318
  { value: "blank", label: "An empty pages folder" },
319
- ]);
319
+ ])) as "starter" | "blank";
320
320
  const pages = path.join(resolved.root, "portal", "pages");
321
321
  if (template === "blank")
322
322
  await addFile(context, path.join(pages, ".gitkeep"), "");
@@ -579,7 +579,9 @@ async function buildPlans(
579
579
  : options.template === "starter"
580
580
  ? "free"
581
581
  : "free");
582
- const billing = o.billing ?? "stripe";
582
+ // Only paid plans need a billing provider. A catalog of free plans takes no payment, so its default is `none` and the
583
+ // config checks clean right away; the tiered template (paid plans) defaults to Stripe, whose key is set with `secrets set`.
584
+ const billing = o.billing ?? (template === "tiered" ? "stripe" : "none");
583
585
  if (template === "tiered" && billing === "none") {
584
586
  throw new ServiceEditError(
585
587
  "The tiered template has paid plans, which need a billing provider.",
@@ -68,6 +68,8 @@ export type PortalPlan = {
68
68
  keysApiBaseUrl?: string;
69
69
  /** The billing provider of the plans service (`none`, `stripe`, ...). `none`: the pages offer no payment. */
70
70
  billing?: string;
71
+ /** Where the account pages link to: only places this portal has (hosted docs, an external docs site, the API page). */
72
+ links?: { docs?: string; api?: string };
71
73
  };
72
74
  /** Account pages. */
73
75
  userPanel: boolean;
@@ -231,6 +233,14 @@ export function planPortal(
231
233
  apiBaseUrl: plans.apiBaseUrl,
232
234
  keysApiBaseUrl: plans.keysApiBaseUrl,
233
235
  billing: plans.billing,
236
+ links: {
237
+ ...(profile.modules.docs
238
+ ? { docs: "/introduction" }
239
+ : profile.docs.url
240
+ ? { docs: profile.docs.url }
241
+ : {}),
242
+ ...(plan.apiReference ? { api: `/${API_REFERENCE_PATH}` } : {}),
243
+ },
234
244
  };
235
245
  }
236
246