@lukoweb/apitogo 0.2.65 → 0.2.71

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
@@ -7661,6 +7661,13 @@ function secretRules(config2, resolved, context, out, skipped, paid) {
7661
7661
  message: `a plan has a price, and the Stripe key (secret ${ref.name}) is not set yet.`,
7662
7662
  hint: `apitogo deploy asks for it. Or run: apitogo secrets set ${ref.name}`
7663
7663
  });
7664
+ } else if (ref.path === "plans.billing.webhookSecret" && !context.knownSecrets.has(ref.name)) {
7665
+ out.warn({
7666
+ code: "SECRET_NOT_SET",
7667
+ path: ref.path,
7668
+ message: `the Stripe webhook secret (${ref.name}) is not set yet, so payments do not activate plans until it is.`,
7669
+ hint: `Deploy first: it prints the webhook address. Add it in Stripe, then run "apitogo secrets set ${ref.name}" with its signing secret and deploy again.`
7670
+ });
7664
7671
  } else if (!context.knownSecrets.has(ref.name)) {
7665
7672
  out.error({
7666
7673
  code: "SECRET_NOT_SET",
@@ -7816,6 +7823,19 @@ function requiredServices(resolved) {
7816
7823
  if (resolved.plans?.enabled === true && consoleOn) needs.push("plans");
7817
7824
  return needs;
7818
7825
  }
7826
+ function onlyConfiguredServices(manifest, resolved) {
7827
+ const configured = {
7828
+ identity: resolved.identity?.enabled === true,
7829
+ gateway: resolved.gateway?.enabled === true,
7830
+ plans: resolved.plans?.enabled === true
7831
+ };
7832
+ const services = Object.fromEntries(
7833
+ Object.entries(manifest.services).filter(
7834
+ ([name]) => !(name in configured) || configured[name]
7835
+ )
7836
+ );
7837
+ return Object.keys(services).length === Object.keys(manifest.services).length ? manifest : { ...manifest, services };
7838
+ }
7819
7839
  function missingManifestServices(resolved, manifest) {
7820
7840
  return requiredServices(resolved).filter((name) => manifest?.services[name] === void 0).map(
7821
7841
  (name) => issue(
@@ -8739,7 +8759,7 @@ async function preparePortal(resolved, manifest, mode, options = {}) {
8739
8759
  ]);
8740
8760
  }
8741
8761
  if (manifest) {
8742
- effective = manifest;
8762
+ effective = onlyConfiguredServices(manifest, resolved);
8743
8763
  manifestSource = "file";
8744
8764
  if (mode === "production") {
8745
8765
  issues.push(...missingManifestServices(resolved, manifest));
@@ -11433,7 +11453,7 @@ import {
11433
11453
  // package.json
11434
11454
  var package_default = {
11435
11455
  name: "@lukoweb/apitogo",
11436
- version: "0.2.65",
11456
+ version: "0.2.71",
11437
11457
  type: "module",
11438
11458
  sideEffects: [
11439
11459
  "**/*.css",
@@ -27011,7 +27031,25 @@ async function apply(argv) {
27011
27031
 
27012
27032
  // src/cli/deploy/command.ts
27013
27033
  import path85 from "node:path";
27034
+
27035
+ // src/cli/common/failure-output.ts
27014
27036
  init_invariant();
27037
+ function errorCode(error) {
27038
+ if (!(error instanceof Error) || !("code" in error)) return void 0;
27039
+ return typeof error.code === "string" && /^[A-Z][A-Z0-9_]*$/.test(error.code) ? error.code : void 0;
27040
+ }
27041
+ function failureJson(error) {
27042
+ return {
27043
+ status: "failed",
27044
+ error: error instanceof Error ? error.message : String(error),
27045
+ code: errorCode(error) ?? null,
27046
+ hint: (error instanceof ApitogoError ? error.developerHint : void 0) ?? null
27047
+ };
27048
+ }
27049
+ function failureText(error) {
27050
+ const { error: message, code, hint } = failureJson(error);
27051
+ return [message, code ? `[${code}]` : void 0, hint ?? void 0].filter(Boolean).join(" ");
27052
+ }
27015
27053
 
27016
27054
  // src/cli/project/create-handler.ts
27017
27055
  import path80 from "node:path";
@@ -27172,6 +27210,8 @@ async function settlePaidPlan(projectId, plan2, options, deps) {
27172
27210
  "Moving a project to another plan is not supported yet. Create a new project with --plan in a new folder, or change the plan in the web app."
27173
27211
  );
27174
27212
  }
27213
+ const timeoutMs = options.paymentTimeoutMs ?? 9e5;
27214
+ let checkoutUrl;
27175
27215
  if (!onThisPlan) {
27176
27216
  let session;
27177
27217
  try {
@@ -27195,14 +27235,17 @@ async function settlePaidPlan(projectId, plan2, options, deps) {
27195
27235
  "The platform gave no checkout link.",
27196
27236
  "Run the command again, or choose the plan in the web app."
27197
27237
  );
27238
+ checkoutUrl = session.url;
27198
27239
  api.say?.(`Pay for the ${label(plan2)} plan here: ${session.url}`);
27199
27240
  if (options.waitForPayment === false)
27200
27241
  return { status: "awaiting_checkout", url: session.url };
27242
+ api.say?.(
27243
+ `Waiting for the payment, up to ${Math.round(timeoutMs / 6e4)} minutes. The project's hosting is set up once it is paid; until then \`apitogo status\` shows it as not ready.`
27244
+ );
27201
27245
  if (options.open !== false)
27202
27246
  await api.openUrl?.(session.url).catch(() => {
27203
27247
  });
27204
27248
  }
27205
- const timeoutMs = options.paymentTimeoutMs ?? 9e5;
27206
27249
  const deadline = now() + timeoutMs;
27207
27250
  for (; ; ) {
27208
27251
  const current = await api.getSubscription(projectId);
@@ -27211,8 +27254,9 @@ async function settlePaidPlan(projectId, plan2, options, deps) {
27211
27254
  if (now() >= deadline)
27212
27255
  throw new ProjectCreateError(
27213
27256
  "CHECKOUT_PENDING",
27214
- `The payment for the ${label(plan2)} plan is not confirmed yet.`,
27215
- `Finish paying with the link above, then run \`apitogo project create --plan ${plan2}\` again; it continues the same project (project.id ${projectId}).`
27257
+ // The link again: in piped output the earlier lines may be gone by now (API-94).
27258
+ `The payment for the ${label(plan2)} plan is not confirmed yet.${checkoutUrl ? ` Pay here: ${checkoutUrl}` : ""}`,
27259
+ `Finish paying${checkoutUrl ? "" : " (the link was printed when the checkout started, or pay on the dashboard)"}, then run \`apitogo project create --plan ${plan2}\` again; it continues the same project (project.id ${projectId}).`
27216
27260
  );
27217
27261
  await sleep5(api.pollIntervalMs ?? 3e3);
27218
27262
  }
@@ -27258,9 +27302,10 @@ async function createLinkedProject(argv, dir, options = {}) {
27258
27302
  const progress = createConsoleProgressReporter(process.stderr, {
27259
27303
  verbose: argv.verbose === true
27260
27304
  });
27305
+ const linkedProject = loadConfigV2(dir).resolved.project.id;
27261
27306
  if (!json)
27262
27307
  printDiagnosticsToConsole(
27263
- `Creating your project: reserving hosting, setting up its addresses, then getting the website ready. This usually takes 1 to 3 minutes.${plan2 && plan2 !== "free" ? " Paying for the plan on the dashboard works too: this command goes on once the payment is in." : ""} Add --verbose to see every step.`
27308
+ `${linkedProject ? `Continuing project ${linkedProject}` : "Creating your project"}: reserving hosting, setting up its addresses, then getting the website ready. This usually takes 1 to 3 minutes.${plan2 && plan2 !== "free" ? " Paying for the plan on the dashboard works too: this command goes on once the payment is in." : ""} Add --verbose to see every step.`
27264
27309
  );
27265
27310
  const result = await createProjectV2(
27266
27311
  {
@@ -27695,6 +27740,51 @@ async function readyCustomDomainOrigin(client, projectId) {
27695
27740
  return `https://${hostname}`;
27696
27741
  }
27697
27742
 
27743
+ // src/cli/deploy/poll-read.ts
27744
+ init_invariant();
27745
+ var PlatformUnreachableError = class extends ApitogoError {
27746
+ code = "PLATFORM_UNREACHABLE";
27747
+ constructor(what, attempts, cause) {
27748
+ const reason = cause instanceof Error ? cause.message : String(cause);
27749
+ super(
27750
+ `Lost contact with the platform while waiting for ${what}: ${attempts} status calls in a row failed (last: ${reason}).`,
27751
+ {
27752
+ title: "Cannot reach the platform",
27753
+ developerHint: "The job may still be running on the platform. Run `apitogo deploy` again to keep waiting; finished steps are not done again.",
27754
+ cause: cause instanceof Error ? cause : void 0
27755
+ }
27756
+ );
27757
+ this.name = "PlatformUnreachableError";
27758
+ }
27759
+ };
27760
+ function isTransientPlatformError(error) {
27761
+ if (error instanceof PlatformApiError) return error.status >= 500;
27762
+ if (!(error instanceof Error)) return false;
27763
+ return error.name === "AbortError" || /fetch failed|econnreset|econnrefused|etimedout|enotfound|socket hang up|network/i.test(
27764
+ `${error.message} ${error.cause instanceof Error ? error.cause.message : ""}`
27765
+ );
27766
+ }
27767
+ async function pollRead(read, options) {
27768
+ const now = options.now ?? Date.now;
27769
+ const maxAttempts = options.maxAttempts ?? 5;
27770
+ const maxErrorMs = options.maxErrorMs ?? 12e4;
27771
+ const delayMs = options.delayMs ?? ((failures2) => Math.min(15e3, 3e3 * failures2));
27772
+ let failures = 0;
27773
+ let firstFailureAt = 0;
27774
+ for (; ; ) {
27775
+ try {
27776
+ return await read();
27777
+ } catch (error) {
27778
+ if (!isTransientPlatformError(error)) throw error;
27779
+ failures++;
27780
+ if (failures === 1) firstFailureAt = now();
27781
+ if (failures >= maxAttempts || now() - firstFailureAt >= maxErrorMs)
27782
+ throw new PlatformUnreachableError(options.what, failures, error);
27783
+ await options.sleep(delayMs(failures));
27784
+ }
27785
+ }
27786
+ }
27787
+
27698
27788
  // src/cli/deploy/gateway.ts
27699
27789
  var GatewayDeployError = class extends ApitogoError {
27700
27790
  constructor(code, message, developerHint) {
@@ -27862,9 +27952,9 @@ async function deployGateway(resolved, projectId, deps, options = {}) {
27862
27952
  await client.activateModule?.(projectId, API_HOSTING_MODULE);
27863
27953
  const deadline = now() + backendTimeoutMs;
27864
27954
  for (; ; ) {
27865
- const state = await client.getInfrastructureStatus?.(
27866
- projectId,
27867
- API_HOSTING_MODULE
27955
+ const state = await pollRead(
27956
+ async () => client.getInfrastructureStatus?.(projectId, API_HOSTING_MODULE),
27957
+ { what: "the API hosting", sleep: sleep5, now }
27868
27958
  );
27869
27959
  if (!state || isTerminalInfrastructureStatus(state.status)) {
27870
27960
  if (state?.status.toLowerCase() === "failed")
@@ -27928,9 +28018,9 @@ async function deployGateway(resolved, projectId, deps, options = {}) {
27928
28018
  if (!started.reused) {
27929
28019
  const deadline = now() + backendTimeoutMs;
27930
28020
  for (; ; ) {
27931
- const state = await client.getDeploymentStatus(
27932
- projectId,
27933
- started.deploymentId
28021
+ const state = await pollRead(
28022
+ () => client.getDeploymentStatus(projectId, started.deploymentId),
28023
+ { what: "your backend deployment", sleep: sleep5, now }
27934
28024
  );
27935
28025
  if (state.status.toLowerCase() === "healthy") break;
27936
28026
  if (isTerminalDeploymentStatus(state.status))
@@ -28023,7 +28113,10 @@ async function deployGateway(resolved, projectId, deps, options = {}) {
28023
28113
  );
28024
28114
  const deadline = now() + timeoutMs;
28025
28115
  for (; ; ) {
28026
- const state = await client.getGatewayReconciliation(projectId, ticket);
28116
+ const state = await pollRead(
28117
+ () => client.getGatewayReconciliation(projectId, ticket),
28118
+ { what: "the gateway config to be applied", sleep: sleep5, now }
28119
+ );
28027
28120
  if (state.state === "Succeeded") break;
28028
28121
  if (state.state === "Failed" || state.state === "Superseded")
28029
28122
  throw new GatewayDeployError(
@@ -28158,7 +28251,7 @@ function webhookStatusLine(status2, now = Date.now()) {
28158
28251
  const ago = minutes < 1 ? "just now" : minutes < 120 ? `${minutes} min ago` : minutes < 48 * 60 ? `${Math.round(minutes / 60)} h ago` : `${Math.round(minutes / (24 * 60))} days ago`;
28159
28252
  return `Stripe webhook: verified (last event ${ago}${status2.lastEventType ? `, ${status2.lastEventType}` : ""})`;
28160
28253
  }
28161
- async function plansInputs(resolved, projectId) {
28254
+ async function plansInputs(resolved, projectId, options = {}) {
28162
28255
  const plans = resolved.plans;
28163
28256
  if (!plans) throw new Error("plans is not in the config.");
28164
28257
  const billing = plans.billing;
@@ -28189,6 +28282,7 @@ async function plansInputs(resolved, projectId) {
28189
28282
  provider: billingOn ? "stripe" : "none",
28190
28283
  apiKey: billingOn ? billing.apiKey : void 0,
28191
28284
  webhookSecret: billingOn ? billing.webhookSecret : void 0,
28285
+ ...billingOn && options.webhookSecretMissing ? { webhookSecretMissing: true } : {},
28192
28286
  successUrl: billingOn ? billing.successUrl : void 0,
28193
28287
  cancelUrl: billingOn ? billing.cancelUrl : void 0
28194
28288
  }),
@@ -28239,6 +28333,18 @@ function billingRefused(error) {
28239
28333
  }
28240
28334
  throw error;
28241
28335
  }
28336
+ async function webhookSecretMissing(client, projectId, reference) {
28337
+ const name = /^secret:([A-Za-z_][A-Za-z0-9_]*)$/.exec(
28338
+ reference?.trim() ?? ""
28339
+ )?.[1];
28340
+ if (!name || !client.listSecrets) return false;
28341
+ try {
28342
+ const { secrets: secrets2 } = await client.listSecrets(projectId);
28343
+ return !secrets2.some((secret) => secret.name === name);
28344
+ } catch {
28345
+ return false;
28346
+ }
28347
+ }
28242
28348
  async function deployPlans(resolved, projectId, deps, options = {}) {
28243
28349
  const plans = resolved.plans;
28244
28350
  if (!plans) throw new Error("plans is not in the config.");
@@ -28248,6 +28354,11 @@ async function deployPlans(resolved, projectId, deps, options = {}) {
28248
28354
  const intervalMs = deps.pollIntervalMs ?? 2e3;
28249
28355
  const backendTimeoutMs = deps.backendTimeoutMs ?? 9e5;
28250
28356
  const linkTimeoutMs = deps.linkTimeoutMs ?? 6e5;
28357
+ const missingWebhook = await webhookSecretMissing(
28358
+ client,
28359
+ projectId,
28360
+ plans.billing.webhookSecret
28361
+ );
28251
28362
  const {
28252
28363
  wanted,
28253
28364
  manifest,
@@ -28258,7 +28369,9 @@ async function deployPlans(resolved, projectId, deps, options = {}) {
28258
28369
  catalogHash,
28259
28370
  backendHash,
28260
28371
  linkHash
28261
- } = await plansInputs(resolved, projectId);
28372
+ } = await plansInputs(resolved, projectId, {
28373
+ webhookSecretMissing: missingWebhook
28374
+ });
28262
28375
  const catalogStep = {
28263
28376
  name: "catalog",
28264
28377
  hash: () => catalogHash,
@@ -28369,7 +28482,11 @@ async function deployPlans(resolved, projectId, deps, options = {}) {
28369
28482
  await client.activateModule(projectId, BACKEND_MODULE);
28370
28483
  const deadline = now() + backendTimeoutMs;
28371
28484
  for (; ; ) {
28372
- fqdn = await backendOf();
28485
+ fqdn = await pollRead(backendOf, {
28486
+ what: "the plans backend",
28487
+ sleep: sleep5,
28488
+ now
28489
+ });
28373
28490
  if (fqdn) break;
28374
28491
  if (now() >= deadline)
28375
28492
  throw new PlansDeployError(
@@ -28387,9 +28504,11 @@ async function deployPlans(resolved, projectId, deps, options = {}) {
28387
28504
  const waitForBackendUpdate = async (jobId, what) => {
28388
28505
  const deadline = now() + linkTimeoutMs;
28389
28506
  for (; ; ) {
28390
- const job = (await client.listProvisioningJobs(projectId)).jobs.find(
28391
- (item) => item.id === jobId
28392
- );
28507
+ const job = (await pollRead(() => client.listProvisioningJobs(projectId), {
28508
+ what: what.text,
28509
+ sleep: sleep5,
28510
+ now
28511
+ })).jobs.find((item) => item.id === jobId);
28393
28512
  const status2 = job?.status.toLowerCase();
28394
28513
  if (status2?.startsWith("completed")) return { jobId };
28395
28514
  if (status2 === "failed")
@@ -28416,7 +28535,9 @@ async function deployPlans(resolved, projectId, deps, options = {}) {
28416
28535
  const body = billingOn ? {
28417
28536
  provider: "stripe",
28418
28537
  apiKey: plans.billing.apiKey,
28419
- webhookSecret: plans.billing.webhookSecret,
28538
+ // A webhook secret that is not set yet is left out: the platform refuses a missing secret, and the deploy's
28539
+ // webhook note says how to add it (API-92).
28540
+ ...missingWebhook ? {} : { webhookSecret: plans.billing.webhookSecret },
28420
28541
  successUrl: plans.billing.successUrl,
28421
28542
  cancelUrl: plans.billing.cancelUrl
28422
28543
  } : { provider: "none" };
@@ -28800,7 +28921,10 @@ async function deployIdentity(resolved, projectId, deps, options = {}) {
28800
28921
  run: async () => {
28801
28922
  const deadline = now() + timeoutMs;
28802
28923
  for (; ; ) {
28803
- const found = await client.getProjectIdentity(projectId);
28924
+ const found = await pollRead(
28925
+ () => client.getProjectIdentity(projectId),
28926
+ { what: "the identity", sleep: sleep5, now }
28927
+ );
28804
28928
  if (found?.status === "Active")
28805
28929
  return {
28806
28930
  issuer: found.issuer,
@@ -29934,6 +30058,8 @@ async function deployV2(options, deps) {
29934
30058
  "Run `apitogo project create` to make a platform project and link this folder. Or pass --project-id <id> (saved as project.id), or set APITOGO_DEFAULT_PROJECT_ID."
29935
30059
  );
29936
30060
  }
30061
+ if ((!only || only.includes("gateway")) && config2.gateway && config2.gateway.enabled !== false && config2.portal && config2.portal.enabled !== false && !portalOnlyLinksExternalDocs(resolved))
30062
+ await recordPortalAddressBeforeBuild(options.dir, deps, projectId);
29937
30063
  const results = [];
29938
30064
  for (const service of DEPLOY_ORDER) {
29939
30065
  if (only && !only.includes(service)) continue;
@@ -30123,7 +30249,8 @@ async function recordPortalAddressBeforeBuild(dir, deps, projectId) {
30123
30249
  if (!resolved.portal?.enabled) return;
30124
30250
  const manifest = await readManifest(resolved.state);
30125
30251
  if (manifest?.services.portal?.url) return;
30126
- const url2 = resolveSwaUrl(await deps.client.getProject(projectId));
30252
+ const project = await deps.client.getProject(projectId);
30253
+ const url2 = project ? resolveSwaUrl(project) : void 0;
30127
30254
  if (!url2) return;
30128
30255
  const profile = portalProfile(resolved);
30129
30256
  await writeManifest(
@@ -30299,22 +30426,12 @@ async function deployProject(argv) {
30299
30426
  }
30300
30427
  }
30301
30428
  async function reportFailure(error, json) {
30302
- const message = error instanceof Error ? error.message : String(error);
30303
- const hint = error instanceof ApitogoError ? error.developerHint : void 0;
30304
30429
  if (json) {
30305
- console.log(
30306
- JSON.stringify({
30307
- status: "failed",
30308
- error: message,
30309
- hint: hint ?? null
30310
- })
30311
- );
30430
+ console.log(JSON.stringify(failureJson(error)));
30312
30431
  process.exitCode = 1;
30313
30432
  return;
30314
30433
  }
30315
- await printCriticalFailureToConsoleAndExit(
30316
- hint ? `${message} ${hint}` : message
30317
- );
30434
+ await printCriticalFailureToConsoleAndExit(failureText(error));
30318
30435
  }
30319
30436
 
30320
30437
  // src/cli/cmds/deploy.ts
@@ -31691,7 +31808,6 @@ var status_default = {
31691
31808
  import path96 from "node:path";
31692
31809
 
31693
31810
  // src/cli/teardown/command.ts
31694
- init_invariant();
31695
31811
  import path94 from "node:path";
31696
31812
  import { createInterface as createInterface7 } from "node:readline/promises";
31697
31813
 
@@ -32092,22 +32208,12 @@ async function teardownProject(argv) {
32092
32208
  );
32093
32209
  printResultToConsole(result.message);
32094
32210
  } catch (error) {
32095
- const message = error instanceof Error ? error.message : String(error);
32096
- const hint = error instanceof ApitogoError ? error.developerHint : void 0;
32097
32211
  if (json) {
32098
- console.log(
32099
- JSON.stringify({
32100
- status: "failed",
32101
- error: message,
32102
- hint: hint ?? null
32103
- })
32104
- );
32212
+ console.log(JSON.stringify(failureJson(error)));
32105
32213
  process.exitCode = 1;
32106
32214
  return;
32107
32215
  }
32108
- await printCriticalFailureToConsoleAndExit(
32109
- hint ? `${message} ${hint}` : message
32110
- );
32216
+ await printCriticalFailureToConsoleAndExit(failureText(error));
32111
32217
  }
32112
32218
  }
32113
32219
 
@@ -32215,7 +32321,6 @@ var teardown_default = {
32215
32321
  };
32216
32322
 
32217
32323
  // src/cli/teardown/reset-command.ts
32218
- init_invariant();
32219
32324
  import path97 from "node:path";
32220
32325
 
32221
32326
  // src/cli/teardown/reset.ts
@@ -32367,17 +32472,8 @@ async function resetProject(argv) {
32367
32472
  console.log(result.message);
32368
32473
  if (result.status === "pending") process.exitCode = 2;
32369
32474
  } catch (error) {
32370
- const message = error instanceof Error ? error.message : String(error);
32371
- const hint = error instanceof ApitogoError ? error.developerHint : void 0;
32372
- if (argv.json)
32373
- console.log(
32374
- JSON.stringify({
32375
- status: "failed",
32376
- error: message,
32377
- hint: hint ?? null
32378
- })
32379
- );
32380
- else console.error(hint ? `${message} ${hint}` : message);
32475
+ if (argv.json) console.log(JSON.stringify(failureJson(error)));
32476
+ else console.error(failureText(error));
32381
32477
  process.exitCode = 1;
32382
32478
  }
32383
32479
  }
@@ -113,8 +113,20 @@ var ProtectedRoutesSchema = z.pipe(
113
113
  z.transform(normalizeProtectedRoutes)
114
114
  );
115
115
 
116
+ // src/lib/util/prerenderedPage.ts
117
+ var PRERENDERED_PATH_ATTRIBUTE = "data-prerendered-path";
118
+ var markPrerenderedPath = (html, urlPath) => html.replace(
119
+ '<div id="root">',
120
+ `<div id="root" ${PRERENDERED_PATH_ATTRIBUTE}="${urlPath.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;")}">`
121
+ );
122
+
116
123
  // src/vite/prerender/utils.ts
117
124
  var prerenderFileFor = (urlPath) => urlPath === "/" ? "/index.html" : urlPath.endsWith("/index") ? `${urlPath}/index.html` : `${urlPath}.html`;
125
+ var SCRIPT_SAFE_LT = `${"\\"}u003c`;
126
+ var redirectPageHtml = (to) => (
127
+ // Every `<` of the target becomes SCRIPT_SAFE_LT, so a target can never close the script early.
128
+ `<!doctype html><link rel="icon" href="data:,"><script>window.location.href=${JSON.stringify(to).replaceAll("<", SCRIPT_SAFE_LT)};</script>`
129
+ );
118
130
 
119
131
  // src/vite/prerender/worker.ts
120
132
  var { template, distDir, serverConfigPath, entryServerPath, writeRedirects } = Piscina.workerData;
@@ -149,7 +161,7 @@ var renderPage = async ({ urlPath }) => {
149
161
  if ([301, 302, 303, 307, 308].includes(response.status)) {
150
162
  const redirectTo = response.headers.get("Location") ?? "";
151
163
  if (writeRedirects) {
152
- const redirectHtml = `<!doctype html><script>window.location.href=${JSON.stringify(redirectTo)};</script>`;
164
+ const redirectHtml = redirectPageHtml(redirectTo);
153
165
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
154
166
  await fs.writeFile(outputPath, redirectHtml);
155
167
  }
@@ -184,7 +196,7 @@ var renderPage = async ({ urlPath }) => {
184
196
  html = bypassResponse.body ? await bypassResponse.text() : "";
185
197
  }
186
198
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
187
- await fs.writeFile(outputPath, fileContent);
199
+ await fs.writeFile(outputPath, markPrerenderedPath(fileContent, urlPath));
188
200
  return {
189
201
  outputPath,
190
202
  statusCode: response.status,
@@ -0,0 +1,3 @@
1
+ export declare const PRERENDERED_PATH_ATTRIBUTE = "data-prerendered-path";
2
+ export declare const markPrerenderedPath: (html: string, urlPath: string) => string;
3
+ export declare const isPrerenderedFor: (prerenderedPath: string | undefined, pathname: string, basePath?: string) => boolean;
@@ -94,8 +94,7 @@ Register the portal in your provider with the redirect address
94
94
 
95
95
  ## Use the same sign-in for your API
96
96
 
97
- By default the gateway checks API keys. To have it check the signed-in person's token as well, set
98
- `gateway.auth`:
97
+ To have the gateway accept the signed-in person's token, set `gateway.auth`:
99
98
 
100
99
  ```json
101
100
  "gateway": {
@@ -103,6 +102,11 @@ By default the gateway checks API keys. To have it check the signed-in person's
103
102
  }
104
103
  ```
105
104
 
105
+ Without plans, the token is then all a caller needs. With plans, the gateway still asks for the API
106
+ key of the caller's plan and not the token. To require both, write
107
+ `"policies": { "access": { "jwt": true, "apiKey": true } }` in the `gateway` section. The
108
+ [Gateway](./gateway.md#access-who-gets-in) page has the full rules.
109
+
106
110
  The API reference's Try it then also offers **Signed-in user**, which sends that token. See
107
111
  [Gateway](./gateway.md).
108
112
 
@@ -117,8 +117,10 @@ A token is a login credential. Read it on your own computer, as above, and never
117
117
  | `apitogo check` or `deploy` says the auth settings are incomplete | One of `metadataUrl`, `issuers` or `audiences` is empty. All three are needed. |
118
118
  | The metadata address shows an error in the browser | The issuer address is wrong, or your provider uses a different path. Check its docs. |
119
119
 
120
- > **Not available yet:** `"mode": "identity"` (APItoGo's own sign-up and login for your end users)
121
- > is a planned service. For now, use `oidc` with your own provider.
120
+ > **Using APItoGo's sign-in?** With an `identity` section in the config, write
121
+ > `"auth": { "mode": "identity" }` instead. The gateway then checks the tokens of your project's own
122
+ > sign-in, and you leave out `metadataUrl`, `issuers` and `audiences`. See
123
+ > [Authentication](./authentication.md).
122
124
 
123
125
  ## 3. The rules (`policies`)
124
126
 
@@ -130,12 +132,23 @@ Rules go in `gateway.policies`. Write them by hand: they are a few lines each.
130
132
  "access": { "jwt": true, "apiKey": false }
131
133
  ```
132
134
 
133
- | Setting | Effect |
134
- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
135
- | `jwt` | `true`: a valid login token is required. |
136
- | `apiKey` | `true`: a key in the `Ocp-Apim-Subscription-Key` header is required. Keys need the plans service, which is not available yet. |
135
+ | Setting | Effect |
136
+ | -------------- | ------------------------------------------------------------------------------------------------------------ |
137
+ | `jwt` | `true`: a valid login token is required. |
138
+ | `apiKey` | `true`: an API key is required. Callers get keys from a plan, so this needs the [plans](./plans.md) service. |
139
+ | `apiKeyHeader` | The header the key is sent in. The default is `X-API-Key`. |
137
140
 
138
- Set `access` yourself and do not rely on the default.
141
+ When you leave `access` out, the gateway asks for one credential, never two by surprise:
142
+
143
+ | Your config | What a caller must send |
144
+ | ------------------------------- | -------------------------------------------------------------- |
145
+ | `auth` set, no plans | A valid login token. No key. |
146
+ | Plans on | An API key from their plan. No token, even when `auth` is set. |
147
+ | You want both a token and a key | Write it out: `"access": { "jwt": true, "apiKey": true }`. |
148
+
149
+ A value you write yourself is always kept. When a call is refused, the `401` answer says which check
150
+ failed: _"the access token is missing, expired or not valid for this API"_ for the token, or _"send
151
+ a valid API key in the X-API-Key header"_ for the key.
139
152
 
140
153
  ### `operations`: open a call to everyone, or close it
141
154
 
@@ -262,13 +275,13 @@ curl -i https://YOUR-GATEWAY-ADDRESS/health
262
275
  curl -i https://YOUR-GATEWAY-ADDRESS/orders
263
276
  ```
264
277
 
265
- | Answer | Meaning |
266
- | ------------------------------- | ------------------------------------------------------------- |
267
- | `200` on a public call | The call is open and your API answered. |
268
- | `401` on a call without a token | The gateway is protecting that call. |
269
- | `404` | The path is not in your OpenAPI file. |
270
- | `429` | The rate limit was reached. |
271
- | `502` or `504` | Your API did not answer. Check `origin.url` and `healthPath`. |
278
+ | Answer | Meaning |
279
+ | ------------------------------- | -------------------------------------------------------------------------------- |
280
+ | `200` on a public call | The call is open and your API answered. |
281
+ | `401` on a call without a token | The gateway is protecting that call. Its message names the missing token or key. |
282
+ | `404` | The path is not in your OpenAPI file. |
283
+ | `429` | The rate limit was reached. |
284
+ | `502` or `504` | Your API did not answer. Check `origin.url` and `healthPath`. |
272
285
 
273
286
  A change is live a few seconds after `deploy` reports it ready: the gateway keeps answering with the
274
287
  previous rules for up to about ten seconds. If a test right after a deploy shows the old behavior,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lukoweb/apitogo",
3
- "version": "0.2.65",
3
+ "version": "0.2.71",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -8,6 +8,10 @@ import config from "virtual:zudoku-config";
8
8
  import "vite/modulepreload-polyfill";
9
9
  import type { ResolvedModulesConfig } from "../config/validators/ModulesSchema.js";
10
10
  import { Bootstrap } from "../lib/components/Bootstrap.js";
11
+ import {
12
+ isPrerenderedFor,
13
+ PRERENDERED_PATH_ATTRIBUTE,
14
+ } from "../lib/util/prerenderedPage.js";
11
15
  import { getRoutesByConfig } from "./main.js";
12
16
 
13
17
  // The build resolved the modules and put them in the config; parsing them again would put zod in this bundle.
@@ -32,9 +36,17 @@ if (process.env.SENTRY_DSN) {
32
36
  });
33
37
  }
34
38
 
35
- if (root.childElementCount > 0) {
39
+ // The host can serve a page for another address (`/404.html` for the sign-in callback): hydrating it would not match
40
+ // the route, so it is rendered fresh instead (API-81).
41
+ const prerenderedFor =
42
+ root.getAttribute(PRERENDERED_PATH_ATTRIBUTE) ?? undefined;
43
+ if (
44
+ root.childElementCount > 0 &&
45
+ isPrerenderedFor(prerenderedFor, window.location.pathname, config.basePath)
46
+ ) {
36
47
  void hydrate(routes);
37
48
  } else {
49
+ root.replaceChildren();
38
50
  void render(routes);
39
51
  }
40
52
 
@@ -185,6 +185,32 @@ export function requiredServices(
185
185
  return needs;
186
186
  }
187
187
 
188
+ /**
189
+ * The manifest without the entries of services the config no longer has (or has switched off). `apitogo remove plans`
190
+ * changes only the config, so services.json still described the plans service, and the portal kept its plan pages and
191
+ * signed in through the plans backend (API-99). The config decides which services exist; the manifest only says where
192
+ * they are. The portal's own entry is always kept.
193
+ */
194
+ export function onlyConfiguredServices(
195
+ manifest: ServiceManifest,
196
+ resolved: Pick<ResolvedConfigV2, "identity" | "gateway" | "plans">,
197
+ ): ServiceManifest {
198
+ const configured: Record<Exclude<ManifestServiceName, "portal">, boolean> = {
199
+ identity: resolved.identity?.enabled === true,
200
+ gateway: resolved.gateway?.enabled === true,
201
+ plans: resolved.plans?.enabled === true,
202
+ };
203
+ const services = Object.fromEntries(
204
+ Object.entries(manifest.services).filter(
205
+ ([name]) =>
206
+ !(name in configured) || configured[name as keyof typeof configured],
207
+ ),
208
+ ) as ServiceManifest["services"];
209
+ return Object.keys(services).length === Object.keys(manifest.services).length
210
+ ? manifest
211
+ : { ...manifest, services };
212
+ }
213
+
188
214
  /** Problems that stop a production build: a required service without a manifest entry. */
189
215
  export function missingManifestServices(
190
216
  resolved: ResolvedConfigV2,
@@ -893,6 +893,17 @@ function secretRules(
893
893
  message: `a plan has a price, and the Stripe key (secret ${ref.name}) is not set yet.`,
894
894
  hint: `apitogo deploy asks for it. Or run: apitogo secrets set ${ref.name}`,
895
895
  });
896
+ } else if (
897
+ ref.path === "plans.billing.webhookSecret" &&
898
+ !context.knownSecrets.has(ref.name)
899
+ ) {
900
+ // The signing secret exists only once Stripe has the webhook address, which the first deploy prints (API-92).
901
+ out.warn({
902
+ code: "SECRET_NOT_SET",
903
+ path: ref.path,
904
+ message: `the Stripe webhook secret (${ref.name}) is not set yet, so payments do not activate plans until it is.`,
905
+ hint: `Deploy first: it prints the webhook address. Add it in Stripe, then run "apitogo secrets set ${ref.name}" with its signing secret and deploy again.`,
906
+ });
896
907
  } else if (!context.knownSecrets.has(ref.name)) {
897
908
  out.error({
898
909
  code: "SECRET_NOT_SET",
@@ -17,6 +17,7 @@ import {
17
17
  import {
18
18
  hasCapability,
19
19
  missingManifestServices,
20
+ onlyConfiguredServices,
20
21
  requiredServices,
21
22
  type ServiceManifest,
22
23
  serviceReady,
@@ -763,7 +764,8 @@ export async function preparePortal(
763
764
  }
764
765
 
765
766
  if (manifest) {
766
- effective = manifest;
767
+ // A service removed from the config (`apitogo remove plans`) still has its entry in services.json (API-99).
768
+ effective = onlyConfiguredServices(manifest, resolved);
767
769
  manifestSource = "file";
768
770
  if (mode === "production") {
769
771
  issues.push(...missingManifestServices(resolved, manifest));
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The attribute the prerender puts on `#root`: the path the page was rendered for. The host can serve a page for
3
+ * another address, for example `/404.html` for the sign-in callback `/oauth/callback`. Hydrating the not-found page
4
+ * with the callback route is a mismatch (React #418 after every sign-in), so the client hydrates only the page that
5
+ * was rendered for this address and renders anything else fresh (API-81).
6
+ */
7
+ export const PRERENDERED_PATH_ATTRIBUTE = "data-prerendered-path";
8
+
9
+ /** Puts the path a page was rendered for on its `#root` element. */
10
+ export const markPrerenderedPath = (html: string, urlPath: string): string =>
11
+ html.replace(
12
+ '<div id="root">',
13
+ `<div id="root" ${PRERENDERED_PATH_ATTRIBUTE}="${urlPath
14
+ .replaceAll("&", "&amp;")
15
+ .replaceAll('"', "&quot;")
16
+ .replaceAll("<", "&lt;")}">`,
17
+ );
18
+
19
+ const normalize = (path: string): string => {
20
+ let decoded = path;
21
+ try {
22
+ decoded = decodeURIComponent(path);
23
+ } catch {
24
+ // A malformed address is compared as it is.
25
+ }
26
+ const trimmed = decoded.replace(/\.html$/, "").replace(/\/+$/, "");
27
+ return trimmed === "" || trimmed === "/index" ? "/" : trimmed;
28
+ };
29
+
30
+ /**
31
+ * Whether the prerendered page in `#root` was rendered for the address being visited, so it can be hydrated. A page
32
+ * without the attribute (built before it existed) is trusted, as before.
33
+ */
34
+ export const isPrerenderedFor = (
35
+ prerenderedPath: string | undefined,
36
+ pathname: string,
37
+ basePath = "",
38
+ ): boolean => {
39
+ if (prerenderedPath === undefined) return true;
40
+ const base = basePath.replace(/\/+$/, "");
41
+ const visited =
42
+ base && (pathname === base || pathname.startsWith(`${base}/`))
43
+ ? pathname.slice(base.length)
44
+ : pathname;
45
+ return normalize(prerenderedPath) === normalize(visited);
46
+ };
@@ -87,3 +87,15 @@ export const routesToPaths = (routes: RouteObject[]): string[] =>
87
87
 
88
88
  export const routesToRewrites = (routes: RouteObject[]): RouteRewrite[] =>
89
89
  collectRewrites(resolveRoutes(routes));
90
+
91
+ /** `<` as a JavaScript string escape: a backslash, then u003c. */
92
+ const SCRIPT_SAFE_LT = `${"\\"}u003c`;
93
+
94
+ /**
95
+ * The page written for a route that redirects, such as `/` of a portal without a home page of its own. It declares an
96
+ * empty icon: without one the browser asks for /favicon.ico before the script runs, a 404 and a console error on every
97
+ * visit of the home page (API-134).
98
+ */
99
+ export const redirectPageHtml = (to: string): string =>
100
+ // Every `<` of the target becomes SCRIPT_SAFE_LT, so a target can never close the script early.
101
+ `<!doctype html><link rel="icon" href="data:,"><script>window.location.href=${JSON.stringify(to).replaceAll("<", SCRIPT_SAFE_LT)};</script>`;
@@ -6,8 +6,9 @@ import { ProtectedRoutesSchema } from "../../config/validators/ProtectedRoutesSc
6
6
  import type { ZudokuConfig } from "../../config/validators/ZudokuConfig.js";
7
7
  import { runPluginTransformConfig } from "../../lib/core/transform-config.js";
8
8
  import { joinUrl } from "../../lib/util/joinUrl.js";
9
+ import { markPrerenderedPath } from "../../lib/util/prerenderedPage.js";
9
10
  import type { WorkerResult } from "./prerender.js";
10
- import { prerenderFileFor } from "./utils.js";
11
+ import { prerenderFileFor, redirectPageHtml } from "./utils.js";
11
12
 
12
13
  type EntryServer = typeof import("../../app/entry.server.js");
13
14
 
@@ -75,7 +76,7 @@ const renderPage = async ({ urlPath }: WorkerData): Promise<WorkerResult> => {
75
76
  const redirectTo = response.headers.get("Location") ?? "";
76
77
 
77
78
  if (writeRedirects) {
78
- const redirectHtml = `<!doctype html><script>window.location.href=${JSON.stringify(redirectTo)};</script>`;
79
+ const redirectHtml = redirectPageHtml(redirectTo);
79
80
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
80
81
  await fs.writeFile(outputPath, redirectHtml);
81
82
  }
@@ -118,9 +119,10 @@ const renderPage = async ({ urlPath }: WorkerData): Promise<WorkerResult> => {
118
119
  html = bypassResponse.body ? await bypassResponse.text() : "";
119
120
  }
120
121
 
121
- // Write the file
122
+ // Write the file, marked with the path it was rendered for: the host also serves some files for other addresses
123
+ // (`404.html` for the sign-in callback), and the client must not hydrate those (API-81).
122
124
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
123
- await fs.writeFile(outputPath, fileContent);
125
+ await fs.writeFile(outputPath, markPrerenderedPath(fileContent, urlPath));
124
126
 
125
127
  return {
126
128
  outputPath,