@lukoweb/apitogo 0.2.54 → 0.2.56

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
@@ -8418,6 +8418,13 @@ async function preparePortal(resolved, manifest, mode, options = {}) {
8418
8418
  for (const page of tsxPages)
8419
8419
  if (page.order !== void 0 && !pageOrders.has(page.route))
8420
8420
  pageOrders.set(page.route, page.order);
8421
+ if (plan2.plansPlugin) {
8422
+ const featured = (await readDemoPlans(resolved.plans?.file))?.find(
8423
+ (item) => typeof item === "object" && item !== null && item.featured === true && typeof item.sku === "string"
8424
+ );
8425
+ if (featured)
8426
+ plan2.plansPlugin.featuredPlanSku = featured.sku.trim().toLowerCase();
8427
+ }
8421
8428
  if (plan2.plansPlugin && plan2.docs) {
8422
8429
  const landing = hostedDocRoutes.some(
8423
8430
  (route) => route === "/" || route === "/index"
@@ -9022,21 +9029,23 @@ var PLAN_TEMPLATES = {
9022
9029
  displayName: "Free",
9023
9030
  default: true,
9024
9031
  limits: {
9025
- // The platform's Free tier allows at most 3 calls per 60 seconds per plan; a higher number is refused.
9026
- rateLimit: { calls: 3, periodSeconds: 60 },
9032
+ // The platform's Free tier allows at most 60 calls per minute per plan (its rate_limit_per_minute ceiling).
9033
+ rateLimit: { calls: 60, periodSeconds: 60 },
9027
9034
  quota: { calls: 1e3, periodDays: 30 }
9028
9035
  }
9029
9036
  }
9030
9037
  ],
9031
- // Sample plans for the starter: a free plan and two with a price on request, so they show on the pricing page and
9032
- // the home page and still deploy on a Free project (a plan with a price needs a paid platform plan and billing).
9038
+ // Sample plans for the full template: a free plan and one paid example (API-124). The example's price is in the file
9039
+ // but it is a contact-us plan, so the first deploy works on a Free project (a plan with a price needs Starter or
9040
+ // Professional and billing). `apitogo plan` names the command that starts selling it. It has no limits of its own,
9041
+ // so it stays within any project's ceiling until the tenant sets them.
9033
9042
  starter: [
9034
9043
  {
9035
9044
  sku: "free",
9036
9045
  displayName: "Free",
9037
9046
  default: true,
9038
9047
  limits: {
9039
- rateLimit: { calls: 3, periodSeconds: 60 },
9048
+ rateLimit: { calls: 60, periodSeconds: 60 },
9040
9049
  quota: { calls: 1e3, periodDays: 30 }
9041
9050
  }
9042
9051
  },
@@ -9044,13 +9053,10 @@ var PLAN_TEMPLATES = {
9044
9053
  sku: "pro",
9045
9054
  displayName: "Pro",
9046
9055
  pricingModel: "custom",
9047
- priceLabel: "Contact us"
9048
- },
9049
- {
9050
- sku: "enterprise",
9051
- displayName: "Enterprise",
9052
- pricingModel: "custom",
9053
- priceLabel: "Contact us"
9056
+ priceLabel: "Contact us",
9057
+ priceAmount: 19,
9058
+ priceCurrency: "usd",
9059
+ billingInterval: "month"
9054
9060
  }
9055
9061
  ],
9056
9062
  tiered: [
@@ -9059,8 +9065,8 @@ var PLAN_TEMPLATES = {
9059
9065
  displayName: "Free",
9060
9066
  default: true,
9061
9067
  limits: {
9062
- // The platform's Free tier allows at most 3 calls per 60 seconds per plan; a higher number is refused.
9063
- rateLimit: { calls: 3, periodSeconds: 60 },
9068
+ // The platform's Free tier allows at most 60 calls per minute per plan (its rate_limit_per_minute ceiling).
9069
+ rateLimit: { calls: 60, periodSeconds: 60 },
9064
9070
  quota: { calls: 1e3, periodDays: 30 }
9065
9071
  }
9066
9072
  },
@@ -10295,6 +10301,17 @@ var PlatformApiClient = class {
10295
10301
  `/api/v1/projects/${projectId}/dev-portal/identity-link`
10296
10302
  );
10297
10303
  }
10304
+ /** The active platform hosting plans, the list apitogo.com shows. */
10305
+ listPlatformPlans() {
10306
+ return this.request("GET", "/api/v1/platform-plans");
10307
+ }
10308
+ /** Whether Stripe events reach the project's plans backend; null when there is none or it did not answer. */
10309
+ getBillingWebhookStatus(projectId) {
10310
+ return this.request(
10311
+ "GET",
10312
+ `/api/v1/projects/${projectId}/dev-portal/billing/webhook-status`
10313
+ );
10314
+ }
10298
10315
  getDevPortalBackend(projectId) {
10299
10316
  return this.request(
10300
10317
  "GET",
@@ -10861,7 +10878,7 @@ import {
10861
10878
  // package.json
10862
10879
  var package_default = {
10863
10880
  name: "@lukoweb/apitogo",
10864
- version: "0.2.54",
10881
+ version: "0.2.56",
10865
10882
  type: "module",
10866
10883
  sideEffects: [
10867
10884
  "**/*.css",
@@ -16020,6 +16037,8 @@ var PlanFilePlanSchema = z14.strictObject({
16020
16037
  pricingModel: z14.enum(["fixed", "custom"]).optional(),
16021
16038
  priceLabel: z14.string().optional(),
16022
16039
  sortOrder: z14.int().optional(),
16040
+ /** Shown as "Popular" on the pricing page (API-124). Only the portal reads it; the platform never sees it. */
16041
+ featured: z14.boolean().optional(),
16023
16042
  /** Action keys such as `get:/orders`. Left out, the plan includes every action of the gateway. */
16024
16043
  includedActions: z14.array(z14.string().trim().min(1)).optional()
16025
16044
  });
@@ -16095,15 +16114,16 @@ function toPlatformPlans(plans, options = {}) {
16095
16114
  }
16096
16115
  } : {}
16097
16116
  } : void 0;
16117
+ const custom = plan2.pricingModel === "custom";
16098
16118
  return {
16099
16119
  sku: plan2.sku.trim().toLowerCase(),
16100
16120
  displayName: plan2.displayName,
16101
16121
  ...limits ? { limitsJson: JSON.stringify(limits) } : {},
16102
16122
  includedActionKeys: plan2.includedActions ?? null,
16103
16123
  ...plan2.pricingModel ? { pricingModel: plan2.pricingModel } : {},
16104
- ...plan2.priceAmount !== void 0 ? { priceAmount: plan2.priceAmount } : {},
16124
+ ...plan2.priceAmount !== void 0 && !custom ? { priceAmount: plan2.priceAmount } : {},
16105
16125
  ...plan2.priceLabel !== void 0 ? { priceLabel: plan2.priceLabel } : {},
16106
- ...plan2.priceCurrency ? { priceCurrency: plan2.priceCurrency } : {},
16126
+ ...plan2.priceCurrency && !custom ? { priceCurrency: plan2.priceCurrency } : {},
16107
16127
  ...plan2.billingInterval ? { billingPeriod: BILLING_PERIOD[plan2.billingInterval] } : {},
16108
16128
  sortOrder: plan2.sortOrder ?? index
16109
16129
  };
@@ -19378,7 +19398,7 @@ function buildWizardPlan(input5) {
19378
19398
  } : {}
19379
19399
  });
19380
19400
  }
19381
- async function upsertPlan(project, plan2) {
19401
+ async function readPlanCatalog(project) {
19382
19402
  const plans = project.resolved.plans;
19383
19403
  if (!plans) {
19384
19404
  throw new ConfigEditError(
@@ -19404,27 +19424,129 @@ async function upsertPlan(project, plan2) {
19404
19424
  }
19405
19425
  catalog = { version: 1, plans: raw.plans };
19406
19426
  }
19407
- const index = catalog.plans.findIndex(
19408
- (item) => isPlainObject3(item) && typeof item.sku === "string" && item.sku.trim().toLowerCase() === plan2.sku
19409
- );
19410
- const next = [...catalog.plans];
19411
- if (index === -1) next.push(plan2);
19412
- else next[index] = plan2;
19413
- await mkdir15(path62.dirname(plans.file), { recursive: true });
19427
+ return { file: plans.file, billing: plans.billing, catalog };
19428
+ }
19429
+ var skuOf = (item) => isPlainObject3(item) && typeof item.sku === "string" ? item.sku.trim().toLowerCase() : void 0;
19430
+ async function writePlanCatalog(file, catalog, plans) {
19431
+ await mkdir15(path62.dirname(file), { recursive: true });
19414
19432
  await writeFile18(
19415
- plans.file,
19416
- `${JSON.stringify({ ...catalog, plans: next }, null, 2)}
19433
+ file,
19434
+ `${JSON.stringify({ ...catalog, plans }, null, 2)}
19417
19435
  `,
19418
19436
  "utf8"
19419
19437
  );
19438
+ }
19439
+ async function upsertPlan(project, plan2) {
19440
+ const { file, billing, catalog } = await readPlanCatalog(project);
19441
+ const index = catalog.plans.findIndex((item) => skuOf(item) === plan2.sku);
19442
+ const next = plan2.featured ? catalog.plans.map((item) => {
19443
+ if (!isPlainObject3(item) || item.featured !== true) return item;
19444
+ const { featured: _featured, ...rest } = item;
19445
+ return rest;
19446
+ }) : [...catalog.plans];
19447
+ if (index === -1) next.push(plan2);
19448
+ else next[index] = plan2;
19449
+ await writePlanCatalog(file, catalog, next);
19420
19450
  const paid = isPaidPlan(plan2);
19421
19451
  return {
19422
19452
  action: index === -1 ? "added" : "updated",
19423
19453
  sku: plan2.sku,
19424
- file: toPosix2(path62.relative(project.dir, plans.file)),
19454
+ file: toPosix2(path62.relative(project.dir, file)),
19425
19455
  paid,
19426
- needsBilling: paid && plans.billing.provider !== "stripe"
19456
+ needsBilling: paid && billing.provider !== "stripe"
19457
+ };
19458
+ }
19459
+ var pairOf = (value, flag, unit) => {
19460
+ const match = /^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(value);
19461
+ const calls = match?.[1] ? Number(match[1]) : Number.NaN;
19462
+ const period = match?.[2] ? Number(match[2]) : Number.NaN;
19463
+ if (!(calls > 0) || !(period > 0)) {
19464
+ throw new ConfigEditError(
19465
+ `${flag} must be <calls>/<${unit}>, both whole numbers above 0, not "${value}".`,
19466
+ flag === "--plan-rate" ? "For example --plan-rate 60/60 is 60 calls per minute." : "For example --plan-quota 50000/30 is 50,000 calls per 30 days."
19467
+ );
19468
+ }
19469
+ return [calls, period];
19470
+ };
19471
+ async function planFromFlags(project, flags) {
19472
+ const sku = flags.sku.trim().toLowerCase();
19473
+ if (!sku) throw new ConfigEditError("--plan-sku must not be empty.");
19474
+ const { catalog } = await readPlanCatalog(project);
19475
+ const existing = catalog.plans.find((item) => skuOf(item) === sku);
19476
+ const base = isPlainObject3(existing) ? { ...existing } : {};
19477
+ const plan2 = {
19478
+ ...base,
19479
+ sku,
19480
+ displayName: flags.name?.trim() || (typeof base.displayName === "string" ? base.displayName : void 0) || sku.charAt(0).toUpperCase() + sku.slice(1)
19427
19481
  };
19482
+ if (flags.price !== void 0) {
19483
+ const price = flags.price.trim().toLowerCase();
19484
+ if (price === "custom") {
19485
+ plan2.pricingModel = "custom";
19486
+ plan2.priceLabel = typeof base.priceLabel === "string" ? base.priceLabel : "Contact us";
19487
+ delete plan2.priceAmount;
19488
+ delete plan2.priceCurrency;
19489
+ delete plan2.billingInterval;
19490
+ } else {
19491
+ const amount = Number(price);
19492
+ if (!Number.isFinite(amount) || amount < 0) {
19493
+ throw new ConfigEditError(
19494
+ `--plan-price must be an amount (0 for free) or "custom", not "${flags.price}".`,
19495
+ "For example --plan-price 19 for 19 a month, or --plan-price custom for a contact-us plan."
19496
+ );
19497
+ }
19498
+ delete plan2.pricingModel;
19499
+ delete plan2.priceLabel;
19500
+ plan2.priceAmount = amount;
19501
+ if (amount > 0) {
19502
+ plan2.priceCurrency = flags.currency?.trim().toLowerCase() || base.priceCurrency || "usd";
19503
+ plan2.billingInterval = flags.interval ?? base.billingInterval ?? "month";
19504
+ } else {
19505
+ delete plan2.priceCurrency;
19506
+ delete plan2.billingInterval;
19507
+ }
19508
+ }
19509
+ } else {
19510
+ if (flags.currency !== void 0)
19511
+ plan2.priceCurrency = flags.currency.trim().toLowerCase();
19512
+ if (flags.interval !== void 0) plan2.billingInterval = flags.interval;
19513
+ }
19514
+ const limits = isPlainObject3(base.limits) ? { ...base.limits } : {};
19515
+ if (flags.rate !== void 0) {
19516
+ const [calls, periodSeconds] = pairOf(flags.rate, "--plan-rate", "seconds");
19517
+ limits.rateLimit = { calls, periodSeconds };
19518
+ }
19519
+ if (flags.quota !== void 0) {
19520
+ const [calls, periodDays] = pairOf(flags.quota, "--plan-quota", "days");
19521
+ limits.quota = { calls, periodDays };
19522
+ }
19523
+ if (Object.keys(limits).length > 0) plan2.limits = limits;
19524
+ if (flags.order !== void 0) {
19525
+ if (!Number.isInteger(flags.order)) {
19526
+ throw new ConfigEditError(
19527
+ `--plan-order must be a whole number, not ${flags.order}.`,
19528
+ "Lower numbers come first on the pricing page."
19529
+ );
19530
+ }
19531
+ plan2.sortOrder = flags.order;
19532
+ }
19533
+ if (flags.featured === true) plan2.featured = true;
19534
+ else if (flags.featured === false) delete plan2.featured;
19535
+ return parsePlan(plan2);
19536
+ }
19537
+ async function removePlan(project, rawSku) {
19538
+ const sku = rawSku.trim().toLowerCase();
19539
+ const { file, catalog } = await readPlanCatalog(project);
19540
+ const next = catalog.plans.filter((item) => skuOf(item) !== sku);
19541
+ if (next.length === catalog.plans.length) {
19542
+ const known = catalog.plans.map(skuOf).filter(Boolean).join(", ");
19543
+ throw new ConfigEditError(
19544
+ `There is no plan "${sku}" in ${toPosix2(path62.relative(project.dir, file))}.`,
19545
+ known ? `The plans are: ${known}.` : "The plans file has no plans."
19546
+ );
19547
+ }
19548
+ await writePlanCatalog(file, catalog, next);
19549
+ return { sku, file: toPosix2(path62.relative(project.dir, file)) };
19428
19550
  }
19429
19551
 
19430
19552
  // src/cli/make-config/wrap-up.ts
@@ -19457,7 +19579,7 @@ function printPageResult(page) {
19457
19579
  );
19458
19580
  }
19459
19581
  }
19460
- function printPlanResult(plan2) {
19582
+ function printPlanResult(plan2, platformTier) {
19461
19583
  printSuccessToConsole(`Plan "${plan2.sku}" ${plan2.action} in ${plan2.file}.`);
19462
19584
  if (plan2.needsBilling) {
19463
19585
  printWarningToConsole(
@@ -19469,7 +19591,8 @@ function printPlanResult(plan2) {
19469
19591
  "Payments go through Stripe: apitogo deploy asks for your Stripe secret key if the project has none yet."
19470
19592
  );
19471
19593
  }
19472
- if (plan2.paid) printPaidPlanPlatformWarning();
19594
+ if (plan2.paid && (platformTier === void 0 || platformTier.toLowerCase() === "free"))
19595
+ printPaidPlanPlatformWarning();
19473
19596
  }
19474
19597
  function printPaidPlanPlatformWarning() {
19475
19598
  printWarningToConsole(
@@ -19642,7 +19765,7 @@ async function promptMakeConfigInteractive(handlers) {
19642
19765
 
19643
19766
  // src/cli/make-config/handler.ts
19644
19767
  function hasMutatorFlags(argv) {
19645
- return argv.title !== void 0 || argv.openapi !== void 0 || argv.source !== void 0 || argv.runtime !== void 0 || argv.runtimeVersion !== void 0 || argv.pageSlug !== void 0 || argv.plan !== void 0 || argv.patch !== void 0 || argv.json === true;
19768
+ return argv.title !== void 0 || argv.openapi !== void 0 || argv.source !== void 0 || argv.runtime !== void 0 || argv.runtimeVersion !== void 0 || argv.pageSlug !== void 0 || argv.plan !== void 0 || argv.planSku !== void 0 || argv.removePlan !== void 0 || argv.patch !== void 0 || argv.json === true;
19646
19769
  }
19647
19770
  function shouldRunInteractive(argv) {
19648
19771
  if (argv.interactive === true) return true;
@@ -19656,6 +19779,15 @@ var describeError = (error) => {
19656
19779
  return hint ? `${error.message}
19657
19780
  ${hint}` : error.message;
19658
19781
  };
19782
+ async function platformTierOf(project) {
19783
+ const projectId = project.resolved.project.id;
19784
+ if (!projectId) return void 0;
19785
+ try {
19786
+ return (await new PlatformApiClient(loadConfig()).getSubscription(projectId)).planTier;
19787
+ } catch {
19788
+ return void 0;
19789
+ }
19790
+ }
19659
19791
  async function applyConfigFlags(project, argv, changes) {
19660
19792
  let next = project;
19661
19793
  let openApi = null;
@@ -19704,6 +19836,7 @@ async function makeConfig(argv) {
19704
19836
  let openApi = null;
19705
19837
  let page = null;
19706
19838
  let plan2 = null;
19839
+ let removed = null;
19707
19840
  if (interactive) {
19708
19841
  const prompted = await promptMakeConfigInteractive({
19709
19842
  defaultSource: project.config.gateway?.origin.source,
@@ -19755,6 +19888,31 @@ async function makeConfig(argv) {
19755
19888
  changes.push(plan2.action === "added" ? "plan_add" : "plan_update");
19756
19889
  }
19757
19890
  }
19891
+ if (argv.planSku !== void 0) {
19892
+ plan2 = await upsertPlan(
19893
+ project,
19894
+ await planFromFlags(project, {
19895
+ sku: argv.planSku,
19896
+ name: argv.planName,
19897
+ price: argv.planPrice,
19898
+ currency: argv.planCurrency,
19899
+ interval: argv.planInterval,
19900
+ rate: argv.planRate,
19901
+ quota: argv.planQuota,
19902
+ order: argv.planOrder,
19903
+ featured: argv.planFeatured
19904
+ })
19905
+ );
19906
+ changes.push(plan2.action === "added" ? "plan_add" : "plan_update");
19907
+ } else if (argv.planName !== void 0 || argv.planPrice !== void 0 || argv.planRate !== void 0 || argv.planQuota !== void 0 || argv.planOrder !== void 0 || argv.planFeatured !== void 0) {
19908
+ throw new ApitogoError("The --plan-* options need --plan-sku.", {
19909
+ developerHint: "Name the plan they change, for example --plan-sku pro --plan-price 19."
19910
+ });
19911
+ }
19912
+ if (argv.removePlan !== void 0) {
19913
+ removed = await removePlan(project, argv.removePlan);
19914
+ changes.push("plan_remove");
19915
+ }
19758
19916
  }
19759
19917
  const message = changes.length === 0 ? "Nothing to change." : (
19760
19918
  // The change codes (page_add, plan_update, ...) are for scripts: they stay in the JSON output (API-126).
@@ -19771,6 +19929,7 @@ async function makeConfig(argv) {
19771
19929
  openApi,
19772
19930
  page,
19773
19931
  plan: plan2,
19932
+ removed,
19774
19933
  message
19775
19934
  },
19776
19935
  null,
@@ -19786,7 +19945,15 @@ async function makeConfig(argv) {
19786
19945
  );
19787
19946
  }
19788
19947
  if (page) printPageResult(page);
19789
- if (plan2) printPlanResult(plan2);
19948
+ if (plan2)
19949
+ printPlanResult(
19950
+ plan2,
19951
+ plan2.paid ? await platformTierOf(project) : void 0
19952
+ );
19953
+ if (removed)
19954
+ printSuccessToConsole(
19955
+ `Removed plan "${removed.sku}" from ${removed.file}. Run \`apitogo plan\` to see what deploy does with it.`
19956
+ );
19790
19957
  printResultToConsole(message);
19791
19958
  }
19792
19959
  } catch (error) {
@@ -19852,7 +20019,44 @@ function addConfigOptions(yargs2) {
19852
20019
  type: "boolean",
19853
20020
  default: false,
19854
20021
  describe: "Update an existing page instead of adding one"
19855
- }).option("plan", {
20022
+ }).option("plan-sku", {
20023
+ type: "string",
20024
+ describe: "Add or change the plan with this SKU in the plans file; the --plan-* options set its fields"
20025
+ }).option("plan-name", {
20026
+ type: "string",
20027
+ describe: "Name of --plan-sku on the pricing page (default: the SKU, capitalised)"
20028
+ }).option("plan-price", {
20029
+ type: "string",
20030
+ describe: "Price of --plan-sku per interval: an amount such as 19 (0 is free), or custom for a contact-us plan"
20031
+ }).option("plan-currency", {
20032
+ type: "string",
20033
+ describe: "Currency of --plan-price (default: usd)"
20034
+ }).option("plan-interval", {
20035
+ type: "string",
20036
+ choices: ["month", "year"],
20037
+ describe: "How often --plan-price is charged (default: month)"
20038
+ }).option("plan-rate", {
20039
+ type: "string",
20040
+ describe: "Rate limit of --plan-sku as <calls>/<seconds>, for example 60/60"
20041
+ }).option("plan-quota", {
20042
+ type: "string",
20043
+ describe: "Quota of --plan-sku as <calls>/<days>, for example 50000/30"
20044
+ }).option("plan-order", {
20045
+ type: "number",
20046
+ describe: "Place of --plan-sku on the pricing page: lower numbers come first"
20047
+ }).option("plan-featured", {
20048
+ type: "boolean",
20049
+ describe: 'Show --plan-sku as "Popular" on the pricing page (--no-plan-featured takes it off); no plan is unless you say so'
20050
+ }).option("remove-plan", {
20051
+ type: "string",
20052
+ describe: "Take the plan with this SKU out of the plans file"
20053
+ }).example(
20054
+ "$0 config --plan-sku pro --plan-name Pro --plan-price 19 --plan-rate 60/60 --plan-quota 50000/30",
20055
+ "A paid plan: 19 USD a month, 60 calls a minute, 50,000 calls per 30 days"
20056
+ ).example(
20057
+ "$0 config --plan-sku enterprise --plan-price custom",
20058
+ "A contact-us plan"
20059
+ ).example("$0 config --remove-plan enterprise", "Take a plan out").option("plan", {
19856
20060
  type: "string",
19857
20061
  describe: "JSON plan added to the plans file (plans.file, default apitogo/plans.json), or replacing the one with the same sku"
19858
20062
  }).option("patch", {
@@ -20199,7 +20403,7 @@ var create_default = {
20199
20403
  };
20200
20404
 
20201
20405
  // src/cli/cmds/deploy.ts
20202
- import path83 from "node:path";
20406
+ import path84 from "node:path";
20203
20407
 
20204
20408
  // src/cli/apply/handler.ts
20205
20409
  import path77 from "node:path";
@@ -22667,6 +22871,25 @@ var PLATFORM_PLAN_CATALOG = [
22667
22871
  ]
22668
22872
  }
22669
22873
  ];
22874
+ var CHOOSABLE = ["free", "starter", "professional"];
22875
+ function toPlanDisplays(plans) {
22876
+ return [...plans].sort((a, b) => a.sortOrder - b.sortOrder).map((plan2) => ({
22877
+ id: plan2.id,
22878
+ displayName: plan2.displayName,
22879
+ priceLabel: [plan2.priceAmount, plan2.pricePeriod].map((part) => part?.trim()).filter(Boolean).join(" "),
22880
+ summary: plan2.tagline,
22881
+ features: plan2.highlights,
22882
+ limitations: CHOOSABLE.includes(plan2.id) ? [] : [`Not chosen here: ${plan2.ctaLabel || "contact us"} on apitogo.com.`]
22883
+ }));
22884
+ }
22885
+ async function loadPlanDisplays(listPlatformPlans) {
22886
+ try {
22887
+ const plans = await listPlatformPlans();
22888
+ if (plans.length > 0) return toPlanDisplays(plans);
22889
+ } catch {
22890
+ }
22891
+ return PLATFORM_PLAN_CATALOG;
22892
+ }
22670
22893
  var PLATFORM_PLAN_USER_PROMPT = "Which API ToGo platform plan do you want for this project \u2014 Free, Starter, or Professional?";
22671
22894
  function formatFreeProjectsQuotaHint(quota) {
22672
22895
  if (quota.remaining > 0) {
@@ -25637,9 +25860,9 @@ function printPlanOptions(catalog) {
25637
25860
  }
25638
25861
  }
25639
25862
  }
25640
- async function pickPlatformPlan() {
25863
+ async function pickPlatformPlan(loadPlans = async () => PLATFORM_PLAN_CATALOG) {
25641
25864
  printDiagnosticsToConsole("Which API ToGo plan should this project use?");
25642
- printPlanOptions(PLATFORM_PLAN_CATALOG);
25865
+ printPlanOptions(await loadPlans());
25643
25866
  printDiagnosticsToConsole("");
25644
25867
  return askPlanChoice();
25645
25868
  }
@@ -25982,7 +26205,7 @@ async function apply(argv) {
25982
26205
  }
25983
26206
 
25984
26207
  // src/cli/deploy/command.ts
25985
- import path82 from "node:path";
26208
+ import path83 from "node:path";
25986
26209
  init_invariant();
25987
26210
 
25988
26211
  // src/cli/project/create-handler.ts
@@ -26201,7 +26424,11 @@ async function createLinkedProject(argv, dir, options = {}) {
26201
26424
  assertConfigChecksOut(await runCheckV2(dir));
26202
26425
  let plan2 = argv.plan;
26203
26426
  if (!plan2 && !json && process.stdin.isTTY && process.stdout.isTTY && !loadConfigV2(dir).resolved.project.id) {
26204
- const picked = await pickPlatformPlan();
26427
+ const picked = await pickPlatformPlan(
26428
+ () => loadPlanDisplays(
26429
+ () => new PlatformApiClient(loadConfig()).listPlatformPlans()
26430
+ )
26431
+ );
26205
26432
  if (!picked) {
26206
26433
  printResultToConsole(
26207
26434
  "No plan selected. Run `apitogo project create --plan free|starter|professional` when you are ready."
@@ -26355,6 +26582,10 @@ async function createProject2(argv) {
26355
26582
  }
26356
26583
  }
26357
26584
 
26585
+ // src/cli/secrets/handler.ts
26586
+ import path79 from "node:path";
26587
+ init_invariant();
26588
+
26358
26589
  // src/cli/secrets/input.ts
26359
26590
  import { createInterface as createInterface6 } from "node:readline/promises";
26360
26591
  var CTRL_C = "";
@@ -26406,8 +26637,164 @@ async function confirm(question) {
26406
26637
  }
26407
26638
  }
26408
26639
 
26640
+ // src/cli/secrets/handler.ts
26641
+ var SECRET_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
26642
+ var SecretsError = class extends ApitogoError {
26643
+ constructor(message, developerHint) {
26644
+ super(message, { developerHint, title: "Cannot change secrets" });
26645
+ this.name = "SecretsError";
26646
+ }
26647
+ };
26648
+ async function resolveProjectId2(argv, deps) {
26649
+ const explicit = argv.projectId?.trim();
26650
+ if (explicit) return explicit;
26651
+ const dir = path79.resolve(process.cwd(), argv.dir ?? ".");
26652
+ if (await isConfigV2(dir)) {
26653
+ const id = loadConfigV2(dir).resolved.project.id;
26654
+ if (id) return id;
26655
+ }
26656
+ if (deps.envProjectId?.trim()) return deps.envProjectId.trim();
26657
+ throw new SecretsError(
26658
+ "This folder is not linked to a project yet.",
26659
+ "Pass --project-id <id>, run `apitogo deploy` once, or set APITOGO_DEFAULT_PROJECT_ID."
26660
+ );
26661
+ }
26662
+ function requireName(name, action) {
26663
+ if (!name) {
26664
+ throw new SecretsError(
26665
+ `secrets ${action} needs a name.`,
26666
+ `For example: apitogo secrets ${action} STRIPE_API_KEY`
26667
+ );
26668
+ }
26669
+ if (!SECRET_NAME.test(name)) {
26670
+ throw new SecretsError(
26671
+ `"${name}" is not a valid secret name.`,
26672
+ "Use letters, digits and underscores, starting with a letter or underscore (at most 128 characters)."
26673
+ );
26674
+ }
26675
+ return name;
26676
+ }
26677
+ async function runSecrets(argv, deps) {
26678
+ if (!["set", "list", "rm"].includes(argv.action)) {
26679
+ throw new SecretsError(
26680
+ `Unknown action: ${argv.action}.`,
26681
+ "Use set, list or rm."
26682
+ );
26683
+ }
26684
+ if (argv.action === "list") {
26685
+ const projectId2 = await resolveProjectId2(argv, deps);
26686
+ const { secrets: secrets2 } = await deps.client.listSecrets(projectId2);
26687
+ return { action: "list", projectId: projectId2, secrets: secrets2 };
26688
+ }
26689
+ const name = requireName(argv.name, argv.action);
26690
+ const projectId = await resolveProjectId2(argv, deps);
26691
+ if (argv.action === "rm") {
26692
+ if (argv.yes !== true) {
26693
+ if (!deps.isTerminal)
26694
+ throw new SecretsError(
26695
+ "This is not an interactive terminal.",
26696
+ "Pass --yes to remove the secret."
26697
+ );
26698
+ if (!await deps.confirm(`Remove secret ${name}? (y/N) `))
26699
+ throw new SecretsError("Cancelled. Nothing was removed.");
26700
+ }
26701
+ await deps.client.deleteSecret(projectId, name);
26702
+ return { action: "rm", projectId, name };
26703
+ }
26704
+ let value;
26705
+ if (deps.isTerminal) {
26706
+ value = await deps.askValue(`Value for ${name} (hidden): `);
26707
+ const again = await deps.askValue("Type it again: ");
26708
+ if (again !== value)
26709
+ throw new SecretsError(
26710
+ "The two values are not the same. Nothing was saved."
26711
+ );
26712
+ } else {
26713
+ value = await deps.readPiped();
26714
+ }
26715
+ if (value.length === 0)
26716
+ throw new SecretsError("The value is empty. Nothing was saved.");
26717
+ const result = await deps.client.setSecret(projectId, name, value);
26718
+ const wiredTo = await wireKnownSecret(argv, result.name);
26719
+ return {
26720
+ action: "set",
26721
+ projectId,
26722
+ name: result.name,
26723
+ created: result.created,
26724
+ ...wiredTo ? { wiredTo } : {}
26725
+ };
26726
+ }
26727
+ var KNOWN_SECRET_PLACES = {
26728
+ STRIPE_WEBHOOK: {
26729
+ path: "plans.billing.webhookSecret",
26730
+ patch: { plans: { billing: { webhookSecret: "secret:STRIPE_WEBHOOK" } } }
26731
+ }
26732
+ };
26733
+ async function wireKnownSecret(argv, name) {
26734
+ const place = KNOWN_SECRET_PLACES[name.toUpperCase()];
26735
+ if (!place) return void 0;
26736
+ const dir = path79.resolve(process.cwd(), argv.dir ?? ".");
26737
+ if (!await isConfigV2(dir)) return void 0;
26738
+ const project = await openConfigProject(dir);
26739
+ const plans = project.resolved.plans;
26740
+ if (!plans || plans.billing.webhookSecret) return void 0;
26741
+ await saveConfigProject(applyConfigPatch(project, place.patch));
26742
+ return place.path;
26743
+ }
26744
+ function formatSecrets(result) {
26745
+ if (result.action === "list") {
26746
+ return result.secrets.length === 0 ? ["No secrets are set for this project."] : result.secrets.map((s) => `${s.name} (updated ${s.updatedAtUtc})`);
26747
+ }
26748
+ if (result.action === "set") {
26749
+ return [
26750
+ `Secret ${result.name} ${result.created ? "created" : "replaced"}.`,
26751
+ ...result.wiredTo ? [
26752
+ `${result.wiredTo} now points to it. Run \`apitogo deploy --only plans\` to apply it.`
26753
+ ] : []
26754
+ ];
26755
+ }
26756
+ return [`Secret ${result.name} removed.`];
26757
+ }
26758
+ async function secrets(argv) {
26759
+ const json = argv.json === true;
26760
+ try {
26761
+ const config2 = loadConfig();
26762
+ const result = await runSecrets(argv, {
26763
+ client: new PlatformApiClient(config2),
26764
+ envProjectId: config2.defaultProjectId,
26765
+ isTerminal: process.stdin.isTTY === true && !json,
26766
+ askValue: (prompt) => readHidden(prompt),
26767
+ readPiped: () => readStdin(),
26768
+ confirm
26769
+ });
26770
+ if (json) {
26771
+ console.log(JSON.stringify({ status: "succeeded", ...result }, null, 2));
26772
+ return;
26773
+ }
26774
+ for (const line2 of formatSecrets(result)) printDiagnosticsToConsole(line2);
26775
+ if (result.action !== "list") printResultToConsole("Done.");
26776
+ } catch (error) {
26777
+ const message = error instanceof Error ? error.message : String(error);
26778
+ const hint = error instanceof ApitogoError ? error.developerHint : void 0;
26779
+ if (json) {
26780
+ console.log(
26781
+ JSON.stringify({
26782
+ status: "failed",
26783
+ error: message,
26784
+ hint: hint ?? null
26785
+ })
26786
+ );
26787
+ process.exitCode = 1;
26788
+ return;
26789
+ }
26790
+ await printCriticalFailureToConsoleAndExit(
26791
+ hint ? `${message} ${hint}` : message
26792
+ );
26793
+ }
26794
+ }
26795
+
26409
26796
  // src/cli/deploy/stripe-key.ts
26410
- import path79 from "node:path";
26797
+ import path80 from "node:path";
26411
26798
  init_invariant();
26412
26799
  var StripeKeyError = class extends ApitogoError {
26413
26800
  constructor(message, developerHint) {
@@ -26425,7 +26812,7 @@ async function ensureStripeKey(argv, deps) {
26425
26812
  if (!wantsPlans(argv.only)) return { status: "not-needed" };
26426
26813
  let resolved;
26427
26814
  try {
26428
- resolved = loadConfigV2(path79.resolve(process.cwd(), argv.dir)).resolved;
26815
+ resolved = loadConfigV2(path80.resolve(process.cwd(), argv.dir)).resolved;
26429
26816
  } catch {
26430
26817
  return { status: "not-needed" };
26431
26818
  }
@@ -26467,13 +26854,11 @@ async function ensureStripeKey(argv, deps) {
26467
26854
  return { status: "saved", name };
26468
26855
  }
26469
26856
 
26470
- // src/cli/deploy/v2.ts
26471
- import { rm as rm11 } from "node:fs/promises";
26857
+ // src/cli/deploy/stripe-webhook.ts
26472
26858
  init_invariant();
26473
26859
 
26474
- // src/cli/portal/report.ts
26475
- import { access as access9 } from "node:fs/promises";
26476
- import path80 from "node:path";
26860
+ // src/cli/deploy/plans.ts
26861
+ init_invariant();
26477
26862
 
26478
26863
  // src/cli/deploy/gateway.ts
26479
26864
  import { createHash as createHash4 } from "node:crypto";
@@ -26884,723 +27269,814 @@ async function deployGateway(resolved, projectId, deps, options = {}) {
26884
27269
  };
26885
27270
  }
26886
27271
 
26887
- // src/cli/deploy/identity.ts
26888
- init_invariant();
26889
- var IdentityDeployError = class extends ApitogoError {
27272
+ // src/cli/deploy/plans.ts
27273
+ var PlansDeployError = class extends ApitogoError {
26890
27274
  constructor(code, message, developerHint) {
26891
- super(message, { developerHint, title: "Cannot deploy identity" });
27275
+ super(message, { developerHint, title: "Cannot deploy plans" });
26892
27276
  this.code = code;
26893
- this.name = "IdentityDeployError";
27277
+ this.name = "PlansDeployError";
26894
27278
  }
26895
27279
  };
26896
- var IDENTITY_STEPS = ["request", "activate"];
26897
- var IDENTITY_CONNECTIONS_STEP = "connections";
26898
- var SECRET_PREFIX = "secret:";
26899
- function normalizedConnections(resolved) {
26900
- return [...resolved.identity?.connections ?? []].map((connection) => ({
26901
- ...connection,
26902
- authority: connection.authority.trim().replace(/\/+$/, "")
26903
- })).sort((a, b) => a.id.localeCompare(b.id));
26904
- }
26905
- function identityConnectionsHash(projectId, resolved) {
26906
- return stableJson({
26907
- projectId,
26908
- connections: normalizedConnections(resolved)
26909
- });
26910
- }
26911
- async function recordedConnectionIds(resolved) {
26912
- const ids = stepOf(
26913
- await readLedger(resolved.state),
26914
- "identity",
26915
- IDENTITY_CONNECTIONS_STEP
26916
- )?.output?.ids;
26917
- return ids ? ids.split(",").filter(Boolean) : [];
26918
- }
26919
- function redirectAddressesOf(output4) {
26920
- return Object.entries(output4 ?? {}).filter(([key]) => key.startsWith("redirect.")).map(([key, redirectUri]) => ({
26921
- id: key.slice("redirect.".length),
26922
- redirectUri
26923
- }));
26924
- }
26925
- function connectionBody(connection) {
26926
- return {
26927
- authority: connection.authority,
26928
- clientId: connection.clientId,
26929
- clientSecret: connection.clientSecret,
26930
- scopes: connection.scopes,
26931
- displayName: connection.displayName
26932
- };
26933
- }
26934
- function referencedSecretNames(connections) {
26935
- return [
26936
- ...new Set(
26937
- connections.map(
26938
- (connection) => connection.clientSecret.slice(SECRET_PREFIX.length)
26939
- )
26940
- )
27280
+ var PLANS_STEPS = [
27281
+ "catalog",
27282
+ "backend",
27283
+ "billing",
27284
+ "identity-link"
27285
+ ];
27286
+ var defaultSleep3 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
27287
+ var BACKEND_MODULE = "dev-portal";
27288
+ var PLANS_CAPABILITIES = { catalog: true, keys: true, usage: true };
27289
+ var STRIPE_WEBHOOK_PATH = "/api/v1/webhooks/stripe";
27290
+ var STRIPE_WEBHOOK_EVENTS2 = [
27291
+ "checkout.session.completed",
27292
+ "customer.subscription.updated",
27293
+ "customer.subscription.deleted",
27294
+ "invoice.payment_failed"
27295
+ ];
27296
+ function billingNotes(billing) {
27297
+ const notes = [
27298
+ billing.mode === "live" ? "Stripe: LIVE mode. Customers pay with real money." : "Stripe: test mode. Use Stripe's test cards; no real money moves."
26941
27299
  ];
27300
+ notes.push(
27301
+ billing.hasWebhookSecret ? `Stripe webhook endpoint: ${billing.webhookUrl}` : `A payment does not activate a plan until Stripe can confirm it. In your Stripe account add the webhook endpoint ${billing.webhookUrl} (events: ${STRIPE_WEBHOOK_EVENTS2.join(", ")}), store its signing secret with "apitogo secrets set STRIPE_WEBHOOK", set plans.billing.webhookSecret to "secret:STRIPE_WEBHOOK", and deploy again.`
27302
+ );
27303
+ return notes;
26942
27304
  }
26943
- function identityProviderSwitch(resolved, known) {
26944
- if (!known?.provider || !resolved.identity) return void 0;
26945
- const to = resolved.identity.provider ?? "apitogo";
26946
- return known.provider === to ? void 0 : { from: known.provider, to };
26947
- }
26948
- function identityProviderSwitchWarning(from, to) {
26949
- 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}.`;
26950
- }
26951
- var PORTAL_CALLBACK_PATH = "/oauth/callback";
26952
- var defaultSleep3 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
26953
- function identityNotDeployableReason(resolved) {
26954
- const identity = resolved.identity;
26955
- if (!identity) return "identity is not in the config";
26956
- if (identity.provider === "oidc" && (!identity.issuer || !identity.clientId))
26957
- return 'identity.provider "oidc" needs issuer and clientId';
26958
- return void 0;
27305
+ function webhookStatusLine(status2, now = Date.now()) {
27306
+ if (!status2) return void 0;
27307
+ if (!status2.webhookSecretConfigured)
27308
+ return 'Stripe webhook: no signing secret yet, so a payment does not activate a plan. Add the endpoint in Stripe, then run "apitogo secrets set STRIPE_WEBHOOK" and deploy.';
27309
+ if (!status2.lastEventAtUtc)
27310
+ return "Stripe webhook: set up, but no event has arrived yet. Make a test payment to see that Stripe reaches it.";
27311
+ const minutes = Math.max(
27312
+ 0,
27313
+ Math.round((now - Date.parse(status2.lastEventAtUtc)) / 6e4)
27314
+ );
27315
+ 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`;
27316
+ return `Stripe webhook: verified (last event ${ago}${status2.lastEventType ? `, ${status2.lastEventType}` : ""})`;
26959
27317
  }
26960
- function identityBodyForRedirect(resolved, redirectUri) {
27318
+ async function plansInputs(resolved, projectId) {
27319
+ const plans = resolved.plans;
27320
+ if (!plans) throw new Error("plans is not in the config.");
27321
+ const billing = plans.billing;
27322
+ const filePlans = await readPlansFile(plans.file);
27323
+ const wanted = toPlatformPlans(filePlans, {
27324
+ billing: billing.provider === "stripe"
27325
+ });
27326
+ const manifest = await readManifest(resolved.state);
27327
+ const ledger = await readLedger(resolved.state);
27328
+ const billingOn = billing.provider === "stripe" && filePlans.some((plan2) => isPaidPlan(plan2));
27329
+ const billingStep = billingOn || stepOf(ledger, "plans", "billing") !== void 0;
27330
+ const identity = manifest?.services.identity;
27331
+ const ownIdentity = Boolean(
27332
+ identity?.provider === "apitogo" && identity.issuer
27333
+ );
26961
27334
  return {
26962
- portalRedirectUris: [redirectUri],
26963
- nativeSignup: resolved.identity?.native ?? true
27335
+ wanted,
27336
+ filePlans,
27337
+ manifest,
27338
+ ownIdentity,
27339
+ catalogHash: stableJson({ projectId, plans: wanted }),
27340
+ backendHash: stableJson({ projectId }),
27341
+ billingOn,
27342
+ billingStep,
27343
+ // Only the references to the project's secrets: a rotated value is applied with --force.
27344
+ billingHash: stableJson({
27345
+ projectId,
27346
+ provider: billingOn ? "stripe" : "none",
27347
+ apiKey: billingOn ? billing.apiKey : void 0,
27348
+ webhookSecret: billingOn ? billing.webhookSecret : void 0,
27349
+ successUrl: billingOn ? billing.successUrl : void 0,
27350
+ cancelUrl: billingOn ? billing.cancelUrl : void 0
27351
+ }),
27352
+ linkHash: stableJson({
27353
+ projectId,
27354
+ issuer: identity?.issuer,
27355
+ clientId: identity?.clientId
27356
+ })
26964
27357
  };
26965
27358
  }
26966
- function identityRequestBody(resolved, portalUrl) {
26967
- return identityBodyForRedirect(
26968
- resolved,
26969
- `${portalUrl.replace(/\/+$/, "")}${PORTAL_CALLBACK_PATH}`
26970
- );
26971
- }
26972
- function identityInputsHash(projectId, body) {
26973
- return stableJson({ projectId, body });
26974
- }
26975
- async function portalUrlOf(resolved, projectId, client) {
26976
- const known = (await readManifest(resolved.state))?.services.portal?.url;
26977
- const url2 = known ?? resolveSwaUrl(await client.getProject(projectId));
26978
- if (!url2)
26979
- throw new IdentityDeployError(
26980
- "IDENTITY_PORTAL_ADDRESS_UNKNOWN",
26981
- "The portal has no public address yet, and the identity needs it for the sign-in redirect.",
26982
- "The address comes with the project's hosting. Run `apitogo status`: while hosting is still being prepared, wait a few minutes and run `apitogo deploy` again. Once hosting is ready, `apitogo deploy --only portal` gives the portal its address, then deploy identity."
26983
- );
26984
- return url2;
27359
+ function plansNotDeployableReason(resolved) {
27360
+ return resolved.plans ? void 0 : "plans is not in the config";
26985
27361
  }
26986
- function rejected2(error) {
26987
- if (error instanceof PlatformApiError && error.status === 409) {
26988
- throw new IdentityDeployError(
26989
- "IDENTITY_SETTINGS_CHANGED",
26990
- "This project already has an identity with other settings, and its audience and portal address cannot be changed in place.",
26991
- "Keep the portal address as it was, or remove the identity first (this deletes every user in it)."
26992
- );
27362
+ async function allActionKeys(client, projectId) {
27363
+ const keys = [];
27364
+ try {
27365
+ for (let page = 1; ; page++) {
27366
+ const result = await client.listGatewayActions(projectId, page, 100);
27367
+ keys.push(...result.items.map((item) => item.actionKey));
27368
+ if (result.items.length === 0 || page * result.pageSize >= result.totalCount)
27369
+ break;
27370
+ }
27371
+ } catch {
27372
+ return [];
26993
27373
  }
27374
+ return keys;
27375
+ }
27376
+ function refused(error) {
26994
27377
  if (error instanceof PlatformApiError && error.status === 400) {
26995
27378
  const lines = describePlatformProblem(error);
26996
- throw new IdentityDeployError(
26997
- "IDENTITY_REJECTED",
26998
- `The platform refused the identity request:
27379
+ throw new PlansDeployError(
27380
+ "PLANS_REJECTED",
27381
+ `The platform refused a plan:
26999
27382
  ${lines.join("\n ") || error.message}`,
27000
- "Fix the cause in apitogo.config.json and run deploy again."
27383
+ "Fix the plan in the plans file and run deploy again."
27001
27384
  );
27002
27385
  }
27003
27386
  throw error;
27004
27387
  }
27005
- function connectionRejected(error, id) {
27006
- if (error instanceof PlatformApiError && error.status === 409) {
27007
- throw new IdentityDeployError(
27008
- "IDENTITY_CONNECTION_REJECTED",
27009
- `The identity is not active yet, so connection "${id}" cannot be saved.`,
27010
- "Run deploy again once the identity is Active."
27011
- );
27012
- }
27388
+ function billingRefused(error) {
27013
27389
  if (error instanceof PlatformApiError && error.status === 400) {
27014
27390
  const lines = describePlatformProblem(error);
27015
- throw new IdentityDeployError(
27016
- "IDENTITY_CONNECTION_REJECTED",
27017
- `The platform refused login connection "${id}":
27018
- ${lines.join("\n ") || error.message}`,
27019
- "Fix the connection in apitogo.config.json (identity.connections) and run deploy again."
27391
+ throw new PlansDeployError(
27392
+ "PLANS_BILLING_REJECTED",
27393
+ `The platform refused the billing settings: ${lines.join(" ") || error.message}`,
27394
+ "Check plans.billing in the config and the project's secrets (apitogo secrets list). The Stripe key starts with sk_ or rk_, the webhook secret with whsec_."
27020
27395
  );
27021
27396
  }
27022
27397
  throw error;
27023
27398
  }
27024
- async function recordOwnProvider(resolved, projectId) {
27025
- const identity = resolved.identity;
27026
- const issuer = identity?.issuer?.replace(/\/+$/, "");
27027
- const clientId = identity?.clientId;
27028
- if (!issuer || !clientId)
27029
- throw new Error("identity.issuer and identity.clientId are needed.");
27030
- const known = (await readManifest(resolved.state))?.services.identity;
27031
- const changed = known?.provider !== "oidc" || known.issuer !== issuer || known.clientId !== clientId;
27032
- if (changed)
27033
- await writeManifest(
27034
- resolved.state,
27035
- withIdentityEntry(await readManifest(resolved.state), {
27036
- projectId,
27037
- projectName: resolved.project.name,
27038
- provider: "oidc",
27039
- issuer,
27040
- clientId,
27041
- nativeSignup: false
27042
- })
27043
- );
27044
- return {
27045
- status: changed ? "deployed" : "unchanged",
27046
- steps: [],
27047
- issuer,
27048
- clientId
27049
- };
27050
- }
27051
- async function deployIdentity(resolved, projectId, deps, options = {}) {
27052
- if (resolved.identity?.provider === "oidc")
27053
- return recordOwnProvider(resolved, projectId);
27399
+ async function deployPlans(resolved, projectId, deps, options = {}) {
27400
+ const plans = resolved.plans;
27401
+ if (!plans) throw new Error("plans is not in the config.");
27054
27402
  const client = deps.client;
27055
27403
  const sleep5 = deps.sleep ?? defaultSleep3;
27056
27404
  const now = deps.now ?? Date.now;
27057
- const timeoutMs = deps.activateTimeoutMs ?? 12e4;
27058
27405
  const intervalMs = deps.pollIntervalMs ?? 2e3;
27059
- const portalUrl = await portalUrlOf(resolved, projectId, client);
27060
- const body = identityRequestBody(resolved, portalUrl);
27061
- const inputsHash = identityInputsHash(projectId, body);
27062
- const requestStep = {
27063
- name: "request",
27064
- hash: () => inputsHash,
27065
- run: async () => {
27066
- try {
27067
- const answer = await client.putProjectIdentity(projectId, body);
27068
- return {
27069
- status: answer.status,
27070
- redirectUri: body.portalRedirectUris[0] ?? ""
27071
- };
27072
- } catch (error) {
27073
- return rejected2(error);
27074
- }
27075
- },
27076
- // An identity that was removed, or that failed, is not a result worth keeping.
27077
- stillValid: async () => {
27078
- const found = await client.getProjectIdentity(projectId);
27079
- return found !== null && found.status !== "Failed" && found.status !== "Released" && found.status !== "Releasing";
27080
- }
27081
- };
27082
- const activateStep = {
27083
- name: "activate",
27084
- hash: () => inputsHash,
27406
+ const backendTimeoutMs = deps.backendTimeoutMs ?? 9e5;
27407
+ const linkTimeoutMs = deps.linkTimeoutMs ?? 6e5;
27408
+ const {
27409
+ wanted,
27410
+ manifest,
27411
+ ownIdentity,
27412
+ billingOn,
27413
+ billingStep,
27414
+ billingHash,
27415
+ catalogHash,
27416
+ backendHash,
27417
+ linkHash
27418
+ } = await plansInputs(resolved, projectId);
27419
+ const catalogStep = {
27420
+ name: "catalog",
27421
+ hash: () => catalogHash,
27085
27422
  run: async () => {
27086
- const deadline = now() + timeoutMs;
27087
- for (; ; ) {
27088
- const found = await client.getProjectIdentity(projectId);
27089
- if (found?.status === "Active")
27090
- return {
27091
- issuer: found.issuer,
27092
- clientId: found.clientId,
27093
- audience: found.audience,
27094
- nativeSignup: String(found.nativeSignup)
27095
- };
27096
- if (found?.status === "Failed")
27097
- throw new IdentityDeployError(
27098
- "IDENTITY_FAILED",
27099
- `The platform could not make the identity: ${[found.errorCode, found.errorMessage].filter(Boolean).join(" ") || "it failed"}.`,
27100
- "Fix the cause and run deploy again; the request is sent again."
27101
- );
27102
- if (now() >= deadline)
27103
- throw new IdentityDeployError(
27104
- "IDENTITY_TIMEOUT",
27105
- `The identity did not become active within ${Math.round(timeoutMs / 1e3)} seconds (state: ${found?.status ?? "not found"}).`,
27106
- "Run deploy again to keep waiting."
27107
- );
27108
- await sleep5(intervalMs);
27423
+ if (wanted.length === 0) return { created: "0", updated: "0" };
27424
+ const fallback = wanted.some((plan2) => plan2.includedActionKeys === null) ? await allActionKeys(client, projectId) : [];
27425
+ const bodies = wanted.map((plan2) => ({
27426
+ ...plan2,
27427
+ includedActionKeys: plan2.includedActionKeys ?? fallback
27428
+ }));
27429
+ const checked = await client.validateGatewayPlans(bodies, { projectId });
27430
+ if (!checked.valid)
27431
+ throw new PlansDeployError(
27432
+ "PLANS_REJECTED",
27433
+ `The platform refused the plans:
27434
+ ${checked.errors.map((issue4) => `${issue4.field}: ${issue4.message}`).join("\n ")}`,
27435
+ "Fix the listed problems in the plans file and run deploy again."
27436
+ );
27437
+ const existing = new Map(
27438
+ (await client.listGatewayPlans(projectId)).map((plan2) => [
27439
+ plan2.sku.toLowerCase(),
27440
+ plan2
27441
+ ])
27442
+ );
27443
+ let created = 0;
27444
+ let updated = 0;
27445
+ let archived = 0;
27446
+ const backendSync = /* @__PURE__ */ new Set();
27447
+ const noteSync = (plan2) => {
27448
+ if (plan2.plansBackendSync) backendSync.add(plan2.plansBackendSync);
27449
+ };
27450
+ for (const body of bodies) {
27451
+ const found = existing.get(body.sku);
27452
+ try {
27453
+ if (found) {
27454
+ const result = await client.updateGatewayPlan(projectId, found.id, {
27455
+ displayName: body.displayName,
27456
+ isActive: true,
27457
+ limitsJson: body.limitsJson ?? null,
27458
+ includedActionKeys: body.includedActionKeys,
27459
+ priceAmount: body.priceAmount,
27460
+ pricingModel: body.pricingModel,
27461
+ priceLabel: body.priceLabel,
27462
+ priceCurrency: body.priceCurrency,
27463
+ billingPeriod: body.billingPeriod,
27464
+ sortOrder: body.sortOrder
27465
+ });
27466
+ noteSync(result);
27467
+ updated++;
27468
+ } else {
27469
+ noteSync(await client.createGatewayPlan(projectId, body));
27470
+ created++;
27471
+ }
27472
+ } catch (error) {
27473
+ refused(error);
27474
+ }
27475
+ }
27476
+ const kept = new Set(bodies.map((body) => body.sku));
27477
+ for (const plan2 of existing.values()) {
27478
+ if (!plan2.isActive || kept.has(plan2.sku.toLowerCase())) continue;
27479
+ try {
27480
+ const result = await client.updateGatewayPlan(projectId, plan2.id, {
27481
+ displayName: plan2.displayName,
27482
+ isActive: false,
27483
+ limitsJson: plan2.limitsJson ?? null,
27484
+ includedActionKeys: plan2.includedActions.map(
27485
+ (action) => action.actionKey
27486
+ ),
27487
+ priceAmount: plan2.priceAmount,
27488
+ pricingModel: plan2.pricingModel,
27489
+ priceCurrency: plan2.priceCurrency,
27490
+ billingPeriod: plan2.billingPeriod,
27491
+ sortOrder: plan2.sortOrder
27492
+ });
27493
+ noteSync(result);
27494
+ archived++;
27495
+ } catch (error) {
27496
+ refused(error);
27497
+ }
27109
27498
  }
27499
+ return {
27500
+ created: String(created),
27501
+ updated: String(updated),
27502
+ archived: String(archived),
27503
+ ...backendSync.size > 0 ? { backendSync: [...backendSync].sort().join(",") } : {}
27504
+ };
27110
27505
  },
27111
- stillValid: async () => (await client.getProjectIdentity(projectId))?.status === "Active"
27506
+ // A plan someone deleted on the platform is not a result worth keeping, and neither is one that should be
27507
+ // archived and is not yet.
27508
+ stillValid: async () => {
27509
+ if (wanted.length === 0) return true;
27510
+ const active2 = (await client.listGatewayPlans(projectId)).filter(
27511
+ (plan2) => plan2.isActive
27512
+ );
27513
+ const present = new Set(active2.map((plan2) => plan2.sku.toLowerCase()));
27514
+ return wanted.every((plan2) => present.has(plan2.sku)) && active2.every(
27515
+ (plan2) => wanted.some((w) => w.sku === plan2.sku.toLowerCase())
27516
+ );
27517
+ }
27112
27518
  };
27113
- const wanted = normalizedConnections(resolved);
27114
- const steps = [requestStep, activateStep];
27115
- if (wanted.length > 0 || (await recordedConnectionIds(resolved)).length > 0)
27116
- steps.push(connectionsStep(resolved, projectId, client, wanted));
27519
+ const backendOf = async () => (await client.getDevPortalBackend(projectId))?.fqdn;
27520
+ const backendStep = {
27521
+ name: "backend",
27522
+ hash: () => backendHash,
27523
+ run: async () => {
27524
+ let fqdn = await backendOf();
27525
+ if (!fqdn) {
27526
+ await client.activateModule(projectId, BACKEND_MODULE);
27527
+ const deadline = now() + backendTimeoutMs;
27528
+ for (; ; ) {
27529
+ fqdn = await backendOf();
27530
+ if (fqdn) break;
27531
+ if (now() >= deadline)
27532
+ throw new PlansDeployError(
27533
+ "PLANS_BACKEND_TIMEOUT",
27534
+ `The plans backend did not get an address within ${Math.round(backendTimeoutMs / 1e3)} seconds.`,
27535
+ "Run deploy again to keep waiting."
27536
+ );
27537
+ await sleep5(intervalMs);
27538
+ }
27539
+ }
27540
+ return { fqdn };
27541
+ },
27542
+ stillValid: async () => Boolean(await backendOf())
27543
+ };
27544
+ const waitForBackendUpdate = async (jobId, what) => {
27545
+ const deadline = now() + linkTimeoutMs;
27546
+ for (; ; ) {
27547
+ const job = (await client.listProvisioningJobs(projectId)).jobs.find(
27548
+ (item) => item.id === jobId
27549
+ );
27550
+ const status2 = job?.status.toLowerCase();
27551
+ if (status2?.startsWith("completed")) return { jobId };
27552
+ if (status2 === "failed")
27553
+ throw new PlansDeployError(
27554
+ `${what.code}_FAILED`,
27555
+ `Applying ${what.text} to the plans backend failed: ${job?.errorMessage ?? "no reason given"}.`,
27556
+ "Run deploy again; it is applied again."
27557
+ );
27558
+ if (now() >= deadline)
27559
+ throw new PlansDeployError(
27560
+ `${what.code}_TIMEOUT`,
27561
+ `Applying ${what.text} did not finish within ${Math.round(linkTimeoutMs / 1e3)} seconds (state: ${status2 ?? "unknown"}).`,
27562
+ "Run deploy again to keep waiting."
27563
+ );
27564
+ await sleep5(intervalMs);
27565
+ }
27566
+ };
27567
+ let billingNote;
27568
+ let webhookUrl;
27569
+ const billingStepDef = {
27570
+ name: "billing",
27571
+ hash: () => billingHash,
27572
+ run: async () => {
27573
+ const body = billingOn ? {
27574
+ provider: "stripe",
27575
+ apiKey: plans.billing.apiKey,
27576
+ webhookSecret: plans.billing.webhookSecret,
27577
+ successUrl: plans.billing.successUrl,
27578
+ cancelUrl: plans.billing.cancelUrl
27579
+ } : { provider: "none" };
27580
+ let queued;
27581
+ try {
27582
+ queued = await client.putDevPortalBilling(projectId, body);
27583
+ } catch (error) {
27584
+ billingRefused(error);
27585
+ }
27586
+ const done = await waitForBackendUpdate(queued.jobId, {
27587
+ code: "PLANS_BILLING",
27588
+ text: "the billing settings"
27589
+ });
27590
+ if (billingOn) {
27591
+ billingNote = billingNotes(queued);
27592
+ webhookUrl = queued.webhookUrl;
27593
+ }
27594
+ return { ...done, mode: queued.mode };
27595
+ }
27596
+ };
27597
+ const linkStep = {
27598
+ name: "identity-link",
27599
+ hash: () => linkHash,
27600
+ run: async () => {
27601
+ const queued = await client.linkDevPortalIdentity(projectId);
27602
+ return waitForBackendUpdate(queued.jobId, {
27603
+ code: "PLANS_LINK",
27604
+ text: "the identity"
27605
+ });
27606
+ }
27607
+ };
27608
+ const steps = [
27609
+ catalogStep,
27610
+ backendStep,
27611
+ ...billingStep ? [billingStepDef] : [],
27612
+ ...ownIdentity ? [linkStep] : []
27613
+ ];
27117
27614
  const outcomes = await runPipeline(
27118
27615
  resolved.state,
27119
- "identity",
27616
+ "plans",
27120
27617
  steps,
27121
- { force: options.force }
27618
+ {
27619
+ force: options.force
27620
+ }
27122
27621
  );
27123
- const activate = outcomes.find((outcome) => outcome.step === "activate");
27124
- const ran = activate !== void 0 && !activate.skipped;
27125
- const known = (await readManifest(resolved.state))?.services.identity;
27126
- let learned;
27127
- if (ran || known?.issuer === void 0) {
27128
- learned = await client.getProjectIdentity(projectId) ?? void 0;
27129
- if (learned?.status === "Active") {
27622
+ const catalog = outcomes.find((outcome) => outcome.step === "catalog");
27623
+ const ran = outcomes.some((outcome) => !outcome.skipped);
27624
+ const known = manifest?.services.plans;
27625
+ let apiBaseUrl = known?.apiBaseUrl;
27626
+ if (ran || known?.apiBaseUrl === void 0) {
27627
+ apiBaseUrl = toApiBaseUrl(await backendOf()) ?? apiBaseUrl;
27628
+ if (apiBaseUrl) {
27629
+ const previous = known?.catalogRevision ?? 0;
27130
27630
  await writeManifest(
27131
27631
  resolved.state,
27132
- withIdentityEntry(await readManifest(resolved.state), {
27632
+ withPlansEntry(await readManifest(resolved.state), {
27133
27633
  projectId,
27134
27634
  projectName: resolved.project.name,
27135
- issuer: learned.issuer,
27136
- clientId: learned.clientId,
27137
- audience: learned.audience,
27138
- nativeSignup: learned.nativeSignup
27635
+ apiBaseUrl,
27636
+ billing: billingOn ? "stripe" : "none",
27637
+ catalogRevision: catalog && !catalog.skipped ? previous + 1 : Math.max(previous, 1),
27638
+ capabilities: billingOn ? { ...PLANS_CAPABILITIES, checkout: true } : PLANS_CAPABILITIES
27139
27639
  })
27140
27640
  );
27141
27641
  }
27142
27642
  }
27143
- const connectionsRan = outcomes.some(
27144
- (outcome) => outcome.step === IDENTITY_CONNECTIONS_STEP && !outcome.skipped
27145
- );
27643
+ let webhookLine;
27644
+ let stripeWebhook;
27645
+ if (billingOn && client.getBillingWebhookStatus) {
27646
+ try {
27647
+ const status2 = await client.getBillingWebhookStatus(projectId);
27648
+ webhookLine = webhookStatusLine(status2, now());
27649
+ const url2 = webhookUrl ?? (apiBaseUrl ? `${apiBaseUrl.replace(/\/+$/, "")}${STRIPE_WEBHOOK_PATH}` : void 0);
27650
+ if (status2 && url2)
27651
+ stripeWebhook = {
27652
+ url: url2,
27653
+ secretConfigured: status2.webhookSecretConfigured
27654
+ };
27655
+ } catch {
27656
+ }
27657
+ }
27658
+ const notes = [...billingNote ?? [], ...webhookLine ? [webhookLine] : []];
27146
27659
  return {
27147
- status: ran || connectionsRan ? "deployed" : "unchanged",
27660
+ status: ran ? "deployed" : "unchanged",
27148
27661
  steps: outcomes.map((outcome) => ({
27149
27662
  step: outcome.step,
27150
27663
  skipped: outcome.skipped
27151
27664
  })),
27152
- issuer: learned?.issuer ?? known?.issuer,
27153
- clientId: learned?.clientId ?? known?.clientId,
27154
- connections: redirectAddressesOf(
27155
- stepOf(
27156
- await readLedger(resolved.state),
27157
- "identity",
27158
- IDENTITY_CONNECTIONS_STEP
27159
- )?.output
27160
- )
27665
+ apiBaseUrl,
27666
+ ...notes.length > 0 ? { notes } : {},
27667
+ ...stripeWebhook ? { stripeWebhook } : {}
27161
27668
  };
27162
27669
  }
27163
- function connectionsStep(resolved, projectId, client, wanted) {
27164
- const {
27165
- putProjectIdentityConnection,
27166
- listProjectIdentityConnections,
27167
- deleteProjectIdentityConnection,
27168
- listSecrets
27169
- } = client;
27170
- if (!putProjectIdentityConnection || !listProjectIdentityConnections || !deleteProjectIdentityConnection || !listSecrets)
27171
- throw new Error("The platform client has no login connection calls.");
27172
- const stamps = async () => {
27173
- const names = referencedSecretNames(wanted);
27174
- if (names.length === 0) return {};
27175
- const listed = await listSecrets.call(client, projectId);
27176
- const byName2 = new Map(listed.secrets.map((s) => [s.name, s.updatedAtUtc]));
27177
- const missing = names.filter((name) => !byName2.has(name));
27178
- if (missing.length > 0)
27179
- throw new IdentityDeployError(
27180
- "IDENTITY_SECRET_MISSING",
27181
- `The secret ${missing.join(", ")} is not set for this project, and a login connection needs it.`,
27182
- `Run \`apitogo secrets set ${missing[0]}\` with the value from the provider, then deploy again.`
27183
- );
27184
- return Object.fromEntries(
27185
- names.map((name) => [name, byName2.get(name)])
27186
- );
27187
- };
27188
- const sameAddress = (a, b) => a.replace(/\/+$/, "").toLowerCase() === b.replace(/\/+$/, "").toLowerCase();
27189
- return {
27190
- name: IDENTITY_CONNECTIONS_STEP,
27191
- hash: () => identityConnectionsHash(projectId, resolved),
27192
- run: async () => {
27193
- const recorded = await stamps();
27194
- const existing = await listProjectIdentityConnections.call(
27195
- client,
27196
- projectId
27197
- );
27198
- const redirects = {};
27199
- for (const connection of wanted) {
27200
- try {
27201
- const saved = await putProjectIdentityConnection.call(
27202
- client,
27203
- projectId,
27204
- connection.id,
27205
- connectionBody(connection)
27206
- );
27207
- redirects[`redirect.${connection.id}`] = saved.redirectUri;
27208
- } catch (error) {
27209
- return connectionRejected(error, connection.id);
27210
- }
27211
- }
27212
- for (const old of existing)
27213
- if (!wanted.some((connection) => connection.id === old.id))
27214
- await deleteProjectIdentityConnection.call(client, projectId, old.id);
27215
- return {
27216
- ids: wanted.map((connection) => connection.id).join(","),
27217
- secretsStamp: stableJson(recorded),
27218
- ...redirects
27219
- };
27220
- },
27221
- // The platform still has exactly these connections, and none of the secrets was changed since.
27222
- stillValid: async () => {
27223
- const found = await listProjectIdentityConnections.call(
27224
- client,
27225
- projectId
27226
- );
27227
- if (found.length !== wanted.length) return false;
27228
- for (const connection of wanted) {
27229
- const there = found.find((f) => f.id === connection.id);
27230
- if (!there || there.clientId !== connection.clientId || !sameAddress(there.authority, connection.authority))
27231
- return false;
27232
- }
27233
- const before = stepOf(
27234
- await readLedger(resolved.state),
27235
- "identity",
27236
- IDENTITY_CONNECTIONS_STEP
27237
- )?.output?.secretsStamp;
27238
- return before === void 0 || before === stableJson(await stamps());
27239
- }
27240
- };
27670
+
27671
+ // src/cli/deploy/stripe-webhook.ts
27672
+ var STRIPE_WEBHOOK_SECRET = "STRIPE_WEBHOOK";
27673
+ var SIGNING_SECRET = /^whsec_[A-Za-z0-9]+$/;
27674
+ var StripeWebhookError = class extends ApitogoError {
27675
+ constructor(message, developerHint) {
27676
+ super(message, { developerHint, title: "Stripe webhook secret" });
27677
+ this.name = "StripeWebhookError";
27678
+ }
27679
+ };
27680
+ async function offerStripeWebhookSecret(output4, deps) {
27681
+ const webhook = output4.services.find(
27682
+ (service) => service.service === "plans"
27683
+ )?.stripeWebhook;
27684
+ if (!webhook || webhook.secretConfigured || !deps.interactive)
27685
+ return { status: "not-needed" };
27686
+ deps.print("");
27687
+ deps.print(
27688
+ "Payments need one more step: Stripe must be able to tell your plans backend that a payment went through."
27689
+ );
27690
+ deps.print(
27691
+ `1. In Stripe, open Developers > Webhooks and add an endpoint with the URL ${webhook.url}`
27692
+ );
27693
+ deps.print(`2. Select the events: ${STRIPE_WEBHOOK_EVENTS2.join(", ")}`);
27694
+ deps.print(
27695
+ "3. Open the endpoint, reveal its signing secret (starts with whsec_) and paste it here."
27696
+ );
27697
+ const secret = (await deps.askHidden(
27698
+ "Stripe webhook signing secret (hidden, Enter to skip): "
27699
+ )).trim();
27700
+ if (!secret) {
27701
+ deps.print(
27702
+ `Skipped. Until it is set, a payment does not activate a plan. Run \`apitogo secrets set ${STRIPE_WEBHOOK_SECRET}\`, then \`apitogo deploy --only plans\`.`
27703
+ );
27704
+ return { status: "skipped" };
27705
+ }
27706
+ if (!SIGNING_SECRET.test(secret))
27707
+ throw new StripeWebhookError(
27708
+ "That is not a Stripe webhook signing secret. It was not saved.",
27709
+ `A signing secret starts with whsec_. Run \`apitogo secrets set ${STRIPE_WEBHOOK_SECRET}\` with the right one, then \`apitogo deploy --only plans\`.`
27710
+ );
27711
+ await deps.client().setSecret(output4.projectId, STRIPE_WEBHOOK_SECRET, secret);
27712
+ const wired = await deps.wireConfig();
27713
+ deps.print(
27714
+ `Signing secret saved as the project secret ${STRIPE_WEBHOOK_SECRET}${wired ? `, and ${wired} points to it` : ""}. Deploying the plans again so the backend gets it.`
27715
+ );
27716
+ return { status: "saved", output: await deps.redeployPlans() };
27241
27717
  }
27242
27718
 
27243
- // src/cli/deploy/plans.ts
27719
+ // src/cli/deploy/v2.ts
27720
+ import { rm as rm11 } from "node:fs/promises";
27244
27721
  init_invariant();
27245
- var PlansDeployError = class extends ApitogoError {
27722
+
27723
+ // src/cli/portal/report.ts
27724
+ import { access as access9 } from "node:fs/promises";
27725
+ import path81 from "node:path";
27726
+
27727
+ // src/cli/deploy/identity.ts
27728
+ init_invariant();
27729
+ var IdentityDeployError = class extends ApitogoError {
27246
27730
  constructor(code, message, developerHint) {
27247
- super(message, { developerHint, title: "Cannot deploy plans" });
27731
+ super(message, { developerHint, title: "Cannot deploy identity" });
27248
27732
  this.code = code;
27249
- this.name = "PlansDeployError";
27733
+ this.name = "IdentityDeployError";
27250
27734
  }
27251
27735
  };
27252
- var PLANS_STEPS = [
27253
- "catalog",
27254
- "backend",
27255
- "billing",
27256
- "identity-link"
27257
- ];
27258
- var defaultSleep4 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
27259
- var BACKEND_MODULE = "dev-portal";
27260
- var PLANS_CAPABILITIES = { catalog: true, keys: true, usage: true };
27261
- var STRIPE_WEBHOOK_EVENTS2 = [
27262
- "checkout.session.completed",
27263
- "customer.subscription.updated",
27264
- "customer.subscription.deleted",
27265
- "invoice.payment_failed"
27266
- ];
27267
- function billingNotes(billing) {
27268
- const notes = [
27269
- billing.mode === "live" ? "Stripe: LIVE mode. Customers pay with real money." : "Stripe: test mode. Use Stripe's test cards; no real money moves."
27270
- ];
27271
- notes.push(
27272
- billing.hasWebhookSecret ? `Stripe webhook endpoint (add it in your Stripe account if it is not there yet): ${billing.webhookUrl}` : `A payment does not activate a plan until Stripe can confirm it. In your Stripe account add the webhook endpoint ${billing.webhookUrl} (events: ${STRIPE_WEBHOOK_EVENTS2.join(", ")}), store its signing secret with "apitogo secrets set STRIPE_WEBHOOK", set plans.billing.webhookSecret to "secret:STRIPE_WEBHOOK", and deploy again.`
27273
- );
27274
- return notes;
27736
+ var IDENTITY_STEPS = ["request", "activate"];
27737
+ var IDENTITY_CONNECTIONS_STEP = "connections";
27738
+ var SECRET_PREFIX = "secret:";
27739
+ function normalizedConnections(resolved) {
27740
+ return [...resolved.identity?.connections ?? []].map((connection) => ({
27741
+ ...connection,
27742
+ authority: connection.authority.trim().replace(/\/+$/, "")
27743
+ })).sort((a, b) => a.id.localeCompare(b.id));
27275
27744
  }
27276
- async function plansInputs(resolved, projectId) {
27277
- const plans = resolved.plans;
27278
- if (!plans) throw new Error("plans is not in the config.");
27279
- const billing = plans.billing;
27280
- const filePlans = await readPlansFile(plans.file);
27281
- const wanted = toPlatformPlans(filePlans, {
27282
- billing: billing.provider === "stripe"
27745
+ function identityConnectionsHash(projectId, resolved) {
27746
+ return stableJson({
27747
+ projectId,
27748
+ connections: normalizedConnections(resolved)
27283
27749
  });
27284
- const manifest = await readManifest(resolved.state);
27285
- const ledger = await readLedger(resolved.state);
27286
- const billingOn = billing.provider === "stripe" && filePlans.some((plan2) => isPaidPlan(plan2));
27287
- const billingStep = billingOn || stepOf(ledger, "plans", "billing") !== void 0;
27288
- const identity = manifest?.services.identity;
27289
- const ownIdentity = Boolean(
27290
- identity?.provider === "apitogo" && identity.issuer
27291
- );
27750
+ }
27751
+ async function recordedConnectionIds(resolved) {
27752
+ const ids = stepOf(
27753
+ await readLedger(resolved.state),
27754
+ "identity",
27755
+ IDENTITY_CONNECTIONS_STEP
27756
+ )?.output?.ids;
27757
+ return ids ? ids.split(",").filter(Boolean) : [];
27758
+ }
27759
+ function redirectAddressesOf(output4) {
27760
+ return Object.entries(output4 ?? {}).filter(([key]) => key.startsWith("redirect.")).map(([key, redirectUri]) => ({
27761
+ id: key.slice("redirect.".length),
27762
+ redirectUri
27763
+ }));
27764
+ }
27765
+ function connectionBody(connection) {
27292
27766
  return {
27293
- wanted,
27294
- manifest,
27295
- ownIdentity,
27296
- catalogHash: stableJson({ projectId, plans: wanted }),
27297
- backendHash: stableJson({ projectId }),
27298
- billingOn,
27299
- billingStep,
27300
- // Only the references to the project's secrets: a rotated value is applied with --force.
27301
- billingHash: stableJson({
27302
- projectId,
27303
- provider: billingOn ? "stripe" : "none",
27304
- apiKey: billingOn ? billing.apiKey : void 0,
27305
- webhookSecret: billingOn ? billing.webhookSecret : void 0,
27306
- successUrl: billingOn ? billing.successUrl : void 0,
27307
- cancelUrl: billingOn ? billing.cancelUrl : void 0
27308
- }),
27309
- linkHash: stableJson({
27310
- projectId,
27311
- issuer: identity?.issuer,
27312
- clientId: identity?.clientId
27313
- })
27767
+ authority: connection.authority,
27768
+ clientId: connection.clientId,
27769
+ clientSecret: connection.clientSecret,
27770
+ scopes: connection.scopes,
27771
+ displayName: connection.displayName
27314
27772
  };
27315
27773
  }
27316
- function plansNotDeployableReason(resolved) {
27317
- return resolved.plans ? void 0 : "plans is not in the config";
27318
- }
27319
- async function allActionKeys(client, projectId) {
27320
- const keys = [];
27321
- try {
27322
- for (let page = 1; ; page++) {
27323
- const result = await client.listGatewayActions(projectId, page, 100);
27324
- keys.push(...result.items.map((item) => item.actionKey));
27325
- if (result.items.length === 0 || page * result.pageSize >= result.totalCount)
27326
- break;
27327
- }
27328
- } catch {
27329
- return [];
27330
- }
27331
- return keys;
27774
+ function referencedSecretNames(connections) {
27775
+ return [
27776
+ ...new Set(
27777
+ connections.map(
27778
+ (connection) => connection.clientSecret.slice(SECRET_PREFIX.length)
27779
+ )
27780
+ )
27781
+ ];
27332
27782
  }
27333
- function refused(error) {
27334
- if (error instanceof PlatformApiError && error.status === 400) {
27335
- const lines = describePlatformProblem(error);
27336
- throw new PlansDeployError(
27337
- "PLANS_REJECTED",
27338
- `The platform refused a plan:
27339
- ${lines.join("\n ") || error.message}`,
27340
- "Fix the plan in the plans file and run deploy again."
27341
- );
27342
- }
27343
- throw error;
27783
+ function identityProviderSwitch(resolved, known) {
27784
+ if (!known?.provider || !resolved.identity) return void 0;
27785
+ const to = resolved.identity.provider ?? "apitogo";
27786
+ return known.provider === to ? void 0 : { from: known.provider, to };
27344
27787
  }
27345
- function billingRefused(error) {
27346
- if (error instanceof PlatformApiError && error.status === 400) {
27347
- const lines = describePlatformProblem(error);
27348
- throw new PlansDeployError(
27349
- "PLANS_BILLING_REJECTED",
27350
- `The platform refused the billing settings: ${lines.join(" ") || error.message}`,
27351
- "Check plans.billing in the config and the project's secrets (apitogo secrets list). The Stripe key starts with sk_ or rk_, the webhook secret with whsec_."
27352
- );
27353
- }
27354
- throw error;
27788
+ function identityProviderSwitchWarning(from, to) {
27789
+ 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}.`;
27355
27790
  }
27356
- async function deployPlans(resolved, projectId, deps, options = {}) {
27357
- const plans = resolved.plans;
27358
- if (!plans) throw new Error("plans is not in the config.");
27791
+ var PORTAL_CALLBACK_PATH = "/oauth/callback";
27792
+ var defaultSleep4 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
27793
+ function identityNotDeployableReason(resolved) {
27794
+ const identity = resolved.identity;
27795
+ if (!identity) return "identity is not in the config";
27796
+ if (identity.provider === "oidc" && (!identity.issuer || !identity.clientId))
27797
+ return 'identity.provider "oidc" needs issuer and clientId';
27798
+ return void 0;
27799
+ }
27800
+ function identityBodyForRedirect(resolved, redirectUri) {
27801
+ return {
27802
+ portalRedirectUris: [redirectUri],
27803
+ nativeSignup: resolved.identity?.native ?? true
27804
+ };
27805
+ }
27806
+ function identityRequestBody(resolved, portalUrl) {
27807
+ return identityBodyForRedirect(
27808
+ resolved,
27809
+ `${portalUrl.replace(/\/+$/, "")}${PORTAL_CALLBACK_PATH}`
27810
+ );
27811
+ }
27812
+ function identityInputsHash(projectId, body) {
27813
+ return stableJson({ projectId, body });
27814
+ }
27815
+ async function portalUrlOf(resolved, projectId, client) {
27816
+ const known = (await readManifest(resolved.state))?.services.portal?.url;
27817
+ const url2 = known ?? resolveSwaUrl(await client.getProject(projectId));
27818
+ if (!url2)
27819
+ throw new IdentityDeployError(
27820
+ "IDENTITY_PORTAL_ADDRESS_UNKNOWN",
27821
+ "The portal has no public address yet, and the identity needs it for the sign-in redirect.",
27822
+ "The address comes with the project's hosting. Run `apitogo status`: while hosting is still being prepared, wait a few minutes and run `apitogo deploy` again. Once hosting is ready, `apitogo deploy --only portal` gives the portal its address, then deploy identity."
27823
+ );
27824
+ return url2;
27825
+ }
27826
+ function rejected2(error) {
27827
+ if (error instanceof PlatformApiError && error.status === 409) {
27828
+ throw new IdentityDeployError(
27829
+ "IDENTITY_SETTINGS_CHANGED",
27830
+ "This project already has an identity with other settings, and its audience and portal address cannot be changed in place.",
27831
+ "Keep the portal address as it was, or remove the identity first (this deletes every user in it)."
27832
+ );
27833
+ }
27834
+ if (error instanceof PlatformApiError && error.status === 400) {
27835
+ const lines = describePlatformProblem(error);
27836
+ throw new IdentityDeployError(
27837
+ "IDENTITY_REJECTED",
27838
+ `The platform refused the identity request:
27839
+ ${lines.join("\n ") || error.message}`,
27840
+ "Fix the cause in apitogo.config.json and run deploy again."
27841
+ );
27842
+ }
27843
+ throw error;
27844
+ }
27845
+ function connectionRejected(error, id) {
27846
+ if (error instanceof PlatformApiError && error.status === 409) {
27847
+ throw new IdentityDeployError(
27848
+ "IDENTITY_CONNECTION_REJECTED",
27849
+ `The identity is not active yet, so connection "${id}" cannot be saved.`,
27850
+ "Run deploy again once the identity is Active."
27851
+ );
27852
+ }
27853
+ if (error instanceof PlatformApiError && error.status === 400) {
27854
+ const lines = describePlatformProblem(error);
27855
+ throw new IdentityDeployError(
27856
+ "IDENTITY_CONNECTION_REJECTED",
27857
+ `The platform refused login connection "${id}":
27858
+ ${lines.join("\n ") || error.message}`,
27859
+ "Fix the connection in apitogo.config.json (identity.connections) and run deploy again."
27860
+ );
27861
+ }
27862
+ throw error;
27863
+ }
27864
+ async function recordOwnProvider(resolved, projectId) {
27865
+ const identity = resolved.identity;
27866
+ const issuer = identity?.issuer?.replace(/\/+$/, "");
27867
+ const clientId = identity?.clientId;
27868
+ if (!issuer || !clientId)
27869
+ throw new Error("identity.issuer and identity.clientId are needed.");
27870
+ const known = (await readManifest(resolved.state))?.services.identity;
27871
+ const changed = known?.provider !== "oidc" || known.issuer !== issuer || known.clientId !== clientId;
27872
+ if (changed)
27873
+ await writeManifest(
27874
+ resolved.state,
27875
+ withIdentityEntry(await readManifest(resolved.state), {
27876
+ projectId,
27877
+ projectName: resolved.project.name,
27878
+ provider: "oidc",
27879
+ issuer,
27880
+ clientId,
27881
+ nativeSignup: false
27882
+ })
27883
+ );
27884
+ return {
27885
+ status: changed ? "deployed" : "unchanged",
27886
+ steps: [],
27887
+ issuer,
27888
+ clientId
27889
+ };
27890
+ }
27891
+ async function deployIdentity(resolved, projectId, deps, options = {}) {
27892
+ if (resolved.identity?.provider === "oidc")
27893
+ return recordOwnProvider(resolved, projectId);
27359
27894
  const client = deps.client;
27360
27895
  const sleep5 = deps.sleep ?? defaultSleep4;
27361
27896
  const now = deps.now ?? Date.now;
27897
+ const timeoutMs = deps.activateTimeoutMs ?? 12e4;
27362
27898
  const intervalMs = deps.pollIntervalMs ?? 2e3;
27363
- const backendTimeoutMs = deps.backendTimeoutMs ?? 9e5;
27364
- const linkTimeoutMs = deps.linkTimeoutMs ?? 6e5;
27365
- const {
27366
- wanted,
27367
- manifest,
27368
- ownIdentity,
27369
- billingOn,
27370
- billingStep,
27371
- billingHash,
27372
- catalogHash,
27373
- backendHash,
27374
- linkHash
27375
- } = await plansInputs(resolved, projectId);
27376
- const catalogStep = {
27377
- name: "catalog",
27378
- hash: () => catalogHash,
27899
+ const portalUrl = await portalUrlOf(resolved, projectId, client);
27900
+ const body = identityRequestBody(resolved, portalUrl);
27901
+ const inputsHash = identityInputsHash(projectId, body);
27902
+ const requestStep = {
27903
+ name: "request",
27904
+ hash: () => inputsHash,
27379
27905
  run: async () => {
27380
- if (wanted.length === 0) return { created: "0", updated: "0" };
27381
- const fallback = wanted.some((plan2) => plan2.includedActionKeys === null) ? await allActionKeys(client, projectId) : [];
27382
- const bodies = wanted.map((plan2) => ({
27383
- ...plan2,
27384
- includedActionKeys: plan2.includedActionKeys ?? fallback
27385
- }));
27386
- const checked = await client.validateGatewayPlans(bodies, { projectId });
27387
- if (!checked.valid)
27388
- throw new PlansDeployError(
27389
- "PLANS_REJECTED",
27390
- `The platform refused the plans:
27391
- ${checked.errors.map((issue4) => `${issue4.field}: ${issue4.message}`).join("\n ")}`,
27392
- "Fix the listed problems in the plans file and run deploy again."
27393
- );
27394
- const existing = new Map(
27395
- (await client.listGatewayPlans(projectId)).map((plan2) => [
27396
- plan2.sku.toLowerCase(),
27397
- plan2
27398
- ])
27399
- );
27400
- let created = 0;
27401
- let updated = 0;
27402
- let archived = 0;
27403
- const backendSync = /* @__PURE__ */ new Set();
27404
- const noteSync = (plan2) => {
27405
- if (plan2.plansBackendSync) backendSync.add(plan2.plansBackendSync);
27406
- };
27407
- for (const body of bodies) {
27408
- const found = existing.get(body.sku);
27409
- try {
27410
- if (found) {
27411
- const result = await client.updateGatewayPlan(projectId, found.id, {
27412
- displayName: body.displayName,
27413
- isActive: true,
27414
- limitsJson: body.limitsJson ?? null,
27415
- includedActionKeys: body.includedActionKeys,
27416
- priceAmount: body.priceAmount,
27417
- pricingModel: body.pricingModel,
27418
- priceLabel: body.priceLabel,
27419
- priceCurrency: body.priceCurrency,
27420
- billingPeriod: body.billingPeriod,
27421
- sortOrder: body.sortOrder
27422
- });
27423
- noteSync(result);
27424
- updated++;
27425
- } else {
27426
- noteSync(await client.createGatewayPlan(projectId, body));
27427
- created++;
27428
- }
27429
- } catch (error) {
27430
- refused(error);
27431
- }
27432
- }
27433
- const kept = new Set(bodies.map((body) => body.sku));
27434
- for (const plan2 of existing.values()) {
27435
- if (!plan2.isActive || kept.has(plan2.sku.toLowerCase())) continue;
27436
- try {
27437
- const result = await client.updateGatewayPlan(projectId, plan2.id, {
27438
- displayName: plan2.displayName,
27439
- isActive: false,
27440
- limitsJson: plan2.limitsJson ?? null,
27441
- includedActionKeys: plan2.includedActions.map(
27442
- (action) => action.actionKey
27443
- ),
27444
- priceAmount: plan2.priceAmount,
27445
- pricingModel: plan2.pricingModel,
27446
- priceCurrency: plan2.priceCurrency,
27447
- billingPeriod: plan2.billingPeriod,
27448
- sortOrder: plan2.sortOrder
27449
- });
27450
- noteSync(result);
27451
- archived++;
27452
- } catch (error) {
27453
- refused(error);
27454
- }
27906
+ try {
27907
+ const answer = await client.putProjectIdentity(projectId, body);
27908
+ return {
27909
+ status: answer.status,
27910
+ redirectUri: body.portalRedirectUris[0] ?? ""
27911
+ };
27912
+ } catch (error) {
27913
+ return rejected2(error);
27455
27914
  }
27456
- return {
27457
- created: String(created),
27458
- updated: String(updated),
27459
- archived: String(archived),
27460
- ...backendSync.size > 0 ? { backendSync: [...backendSync].sort().join(",") } : {}
27461
- };
27462
27915
  },
27463
- // A plan someone deleted on the platform is not a result worth keeping, and neither is one that should be
27464
- // archived and is not yet.
27916
+ // An identity that was removed, or that failed, is not a result worth keeping.
27465
27917
  stillValid: async () => {
27466
- if (wanted.length === 0) return true;
27467
- const active2 = (await client.listGatewayPlans(projectId)).filter(
27468
- (plan2) => plan2.isActive
27469
- );
27470
- const present = new Set(active2.map((plan2) => plan2.sku.toLowerCase()));
27471
- return wanted.every((plan2) => present.has(plan2.sku)) && active2.every(
27472
- (plan2) => wanted.some((w) => w.sku === plan2.sku.toLowerCase())
27473
- );
27918
+ const found = await client.getProjectIdentity(projectId);
27919
+ return found !== null && found.status !== "Failed" && found.status !== "Released" && found.status !== "Releasing";
27474
27920
  }
27475
27921
  };
27476
- const backendOf = async () => (await client.getDevPortalBackend(projectId))?.fqdn;
27477
- const backendStep = {
27478
- name: "backend",
27479
- hash: () => backendHash,
27922
+ const activateStep = {
27923
+ name: "activate",
27924
+ hash: () => inputsHash,
27480
27925
  run: async () => {
27481
- let fqdn = await backendOf();
27482
- if (!fqdn) {
27483
- await client.activateModule(projectId, BACKEND_MODULE);
27484
- const deadline = now() + backendTimeoutMs;
27485
- for (; ; ) {
27486
- fqdn = await backendOf();
27487
- if (fqdn) break;
27488
- if (now() >= deadline)
27489
- throw new PlansDeployError(
27490
- "PLANS_BACKEND_TIMEOUT",
27491
- `The plans backend did not get an address within ${Math.round(backendTimeoutMs / 1e3)} seconds.`,
27492
- "Run deploy again to keep waiting."
27493
- );
27494
- await sleep5(intervalMs);
27495
- }
27926
+ const deadline = now() + timeoutMs;
27927
+ for (; ; ) {
27928
+ const found = await client.getProjectIdentity(projectId);
27929
+ if (found?.status === "Active")
27930
+ return {
27931
+ issuer: found.issuer,
27932
+ clientId: found.clientId,
27933
+ audience: found.audience,
27934
+ nativeSignup: String(found.nativeSignup)
27935
+ };
27936
+ if (found?.status === "Failed")
27937
+ throw new IdentityDeployError(
27938
+ "IDENTITY_FAILED",
27939
+ `The platform could not make the identity: ${[found.errorCode, found.errorMessage].filter(Boolean).join(" ") || "it failed"}.`,
27940
+ "Fix the cause and run deploy again; the request is sent again."
27941
+ );
27942
+ if (now() >= deadline)
27943
+ throw new IdentityDeployError(
27944
+ "IDENTITY_TIMEOUT",
27945
+ `The identity did not become active within ${Math.round(timeoutMs / 1e3)} seconds (state: ${found?.status ?? "not found"}).`,
27946
+ "Run deploy again to keep waiting."
27947
+ );
27948
+ await sleep5(intervalMs);
27496
27949
  }
27497
- return { fqdn };
27498
27950
  },
27499
- stillValid: async () => Boolean(await backendOf())
27500
- };
27501
- const waitForBackendUpdate = async (jobId, what) => {
27502
- const deadline = now() + linkTimeoutMs;
27503
- for (; ; ) {
27504
- const job = (await client.listProvisioningJobs(projectId)).jobs.find(
27505
- (item) => item.id === jobId
27506
- );
27507
- const status2 = job?.status.toLowerCase();
27508
- if (status2?.startsWith("completed")) return { jobId };
27509
- if (status2 === "failed")
27510
- throw new PlansDeployError(
27511
- `${what.code}_FAILED`,
27512
- `Applying ${what.text} to the plans backend failed: ${job?.errorMessage ?? "no reason given"}.`,
27513
- "Run deploy again; it is applied again."
27514
- );
27515
- if (now() >= deadline)
27516
- throw new PlansDeployError(
27517
- `${what.code}_TIMEOUT`,
27518
- `Applying ${what.text} did not finish within ${Math.round(linkTimeoutMs / 1e3)} seconds (state: ${status2 ?? "unknown"}).`,
27519
- "Run deploy again to keep waiting."
27520
- );
27521
- await sleep5(intervalMs);
27522
- }
27523
- };
27524
- let billingNote;
27525
- const billingStepDef = {
27526
- name: "billing",
27527
- hash: () => billingHash,
27528
- run: async () => {
27529
- const body = billingOn ? {
27530
- provider: "stripe",
27531
- apiKey: plans.billing.apiKey,
27532
- webhookSecret: plans.billing.webhookSecret,
27533
- successUrl: plans.billing.successUrl,
27534
- cancelUrl: plans.billing.cancelUrl
27535
- } : { provider: "none" };
27536
- let queued;
27537
- try {
27538
- queued = await client.putDevPortalBilling(projectId, body);
27539
- } catch (error) {
27540
- billingRefused(error);
27541
- }
27542
- const done = await waitForBackendUpdate(queued.jobId, {
27543
- code: "PLANS_BILLING",
27544
- text: "the billing settings"
27545
- });
27546
- if (billingOn) billingNote = billingNotes(queued);
27547
- return { ...done, mode: queued.mode };
27548
- }
27549
- };
27550
- const linkStep = {
27551
- name: "identity-link",
27552
- hash: () => linkHash,
27553
- run: async () => {
27554
- const queued = await client.linkDevPortalIdentity(projectId);
27555
- return waitForBackendUpdate(queued.jobId, {
27556
- code: "PLANS_LINK",
27557
- text: "the identity"
27558
- });
27559
- }
27951
+ stillValid: async () => (await client.getProjectIdentity(projectId))?.status === "Active"
27560
27952
  };
27561
- const steps = [
27562
- catalogStep,
27563
- backendStep,
27564
- ...billingStep ? [billingStepDef] : [],
27565
- ...ownIdentity ? [linkStep] : []
27566
- ];
27953
+ const wanted = normalizedConnections(resolved);
27954
+ const steps = [requestStep, activateStep];
27955
+ if (wanted.length > 0 || (await recordedConnectionIds(resolved)).length > 0)
27956
+ steps.push(connectionsStep(resolved, projectId, client, wanted));
27567
27957
  const outcomes = await runPipeline(
27568
27958
  resolved.state,
27569
- "plans",
27959
+ "identity",
27570
27960
  steps,
27571
- {
27572
- force: options.force
27573
- }
27961
+ { force: options.force }
27574
27962
  );
27575
- const catalog = outcomes.find((outcome) => outcome.step === "catalog");
27576
- const ran = outcomes.some((outcome) => !outcome.skipped);
27577
- const known = manifest?.services.plans;
27578
- let apiBaseUrl = known?.apiBaseUrl;
27579
- if (ran || known?.apiBaseUrl === void 0) {
27580
- apiBaseUrl = toApiBaseUrl(await backendOf()) ?? apiBaseUrl;
27581
- if (apiBaseUrl) {
27582
- const previous = known?.catalogRevision ?? 0;
27963
+ const activate = outcomes.find((outcome) => outcome.step === "activate");
27964
+ const ran = activate !== void 0 && !activate.skipped;
27965
+ const known = (await readManifest(resolved.state))?.services.identity;
27966
+ let learned;
27967
+ if (ran || known?.issuer === void 0) {
27968
+ learned = await client.getProjectIdentity(projectId) ?? void 0;
27969
+ if (learned?.status === "Active") {
27583
27970
  await writeManifest(
27584
27971
  resolved.state,
27585
- withPlansEntry(await readManifest(resolved.state), {
27586
- projectId,
27587
- projectName: resolved.project.name,
27588
- apiBaseUrl,
27589
- billing: billingOn ? "stripe" : "none",
27590
- catalogRevision: catalog && !catalog.skipped ? previous + 1 : Math.max(previous, 1),
27591
- capabilities: billingOn ? { ...PLANS_CAPABILITIES, checkout: true } : PLANS_CAPABILITIES
27972
+ withIdentityEntry(await readManifest(resolved.state), {
27973
+ projectId,
27974
+ projectName: resolved.project.name,
27975
+ issuer: learned.issuer,
27976
+ clientId: learned.clientId,
27977
+ audience: learned.audience,
27978
+ nativeSignup: learned.nativeSignup
27592
27979
  })
27593
27980
  );
27594
27981
  }
27595
27982
  }
27983
+ const connectionsRan = outcomes.some(
27984
+ (outcome) => outcome.step === IDENTITY_CONNECTIONS_STEP && !outcome.skipped
27985
+ );
27596
27986
  return {
27597
- status: ran ? "deployed" : "unchanged",
27987
+ status: ran || connectionsRan ? "deployed" : "unchanged",
27598
27988
  steps: outcomes.map((outcome) => ({
27599
27989
  step: outcome.step,
27600
27990
  skipped: outcome.skipped
27601
27991
  })),
27602
- apiBaseUrl,
27603
- ...billingNote ? { notes: billingNote } : {}
27992
+ issuer: learned?.issuer ?? known?.issuer,
27993
+ clientId: learned?.clientId ?? known?.clientId,
27994
+ connections: redirectAddressesOf(
27995
+ stepOf(
27996
+ await readLedger(resolved.state),
27997
+ "identity",
27998
+ IDENTITY_CONNECTIONS_STEP
27999
+ )?.output
28000
+ )
28001
+ };
28002
+ }
28003
+ function connectionsStep(resolved, projectId, client, wanted) {
28004
+ const {
28005
+ putProjectIdentityConnection,
28006
+ listProjectIdentityConnections,
28007
+ deleteProjectIdentityConnection,
28008
+ listSecrets
28009
+ } = client;
28010
+ if (!putProjectIdentityConnection || !listProjectIdentityConnections || !deleteProjectIdentityConnection || !listSecrets)
28011
+ throw new Error("The platform client has no login connection calls.");
28012
+ const stamps = async () => {
28013
+ const names = referencedSecretNames(wanted);
28014
+ if (names.length === 0) return {};
28015
+ const listed = await listSecrets.call(client, projectId);
28016
+ const byName2 = new Map(listed.secrets.map((s) => [s.name, s.updatedAtUtc]));
28017
+ const missing = names.filter((name) => !byName2.has(name));
28018
+ if (missing.length > 0)
28019
+ throw new IdentityDeployError(
28020
+ "IDENTITY_SECRET_MISSING",
28021
+ `The secret ${missing.join(", ")} is not set for this project, and a login connection needs it.`,
28022
+ `Run \`apitogo secrets set ${missing[0]}\` with the value from the provider, then deploy again.`
28023
+ );
28024
+ return Object.fromEntries(
28025
+ names.map((name) => [name, byName2.get(name)])
28026
+ );
28027
+ };
28028
+ const sameAddress = (a, b) => a.replace(/\/+$/, "").toLowerCase() === b.replace(/\/+$/, "").toLowerCase();
28029
+ return {
28030
+ name: IDENTITY_CONNECTIONS_STEP,
28031
+ hash: () => identityConnectionsHash(projectId, resolved),
28032
+ run: async () => {
28033
+ const recorded = await stamps();
28034
+ const existing = await listProjectIdentityConnections.call(
28035
+ client,
28036
+ projectId
28037
+ );
28038
+ const redirects = {};
28039
+ for (const connection of wanted) {
28040
+ try {
28041
+ const saved = await putProjectIdentityConnection.call(
28042
+ client,
28043
+ projectId,
28044
+ connection.id,
28045
+ connectionBody(connection)
28046
+ );
28047
+ redirects[`redirect.${connection.id}`] = saved.redirectUri;
28048
+ } catch (error) {
28049
+ return connectionRejected(error, connection.id);
28050
+ }
28051
+ }
28052
+ for (const old of existing)
28053
+ if (!wanted.some((connection) => connection.id === old.id))
28054
+ await deleteProjectIdentityConnection.call(client, projectId, old.id);
28055
+ return {
28056
+ ids: wanted.map((connection) => connection.id).join(","),
28057
+ secretsStamp: stableJson(recorded),
28058
+ ...redirects
28059
+ };
28060
+ },
28061
+ // The platform still has exactly these connections, and none of the secrets was changed since.
28062
+ stillValid: async () => {
28063
+ const found = await listProjectIdentityConnections.call(
28064
+ client,
28065
+ projectId
28066
+ );
28067
+ if (found.length !== wanted.length) return false;
28068
+ for (const connection of wanted) {
28069
+ const there = found.find((f) => f.id === connection.id);
28070
+ if (!there || there.clientId !== connection.clientId || !sameAddress(there.authority, connection.authority))
28071
+ return false;
28072
+ }
28073
+ const before = stepOf(
28074
+ await readLedger(resolved.state),
28075
+ "identity",
28076
+ IDENTITY_CONNECTIONS_STEP
28077
+ )?.output?.secretsStamp;
28078
+ return before === void 0 || before === stableJson(await stamps());
28079
+ }
27604
28080
  };
27605
28081
  }
27606
28082
 
@@ -27742,6 +28218,12 @@ async function planPlansService(resolved, ledger, plansRemote2) {
27742
28218
  const archiving = await plansToArchive(projectId, inputs.wanted, plansRemote2);
27743
28219
  const details = [
27744
28220
  ...inputs.wanted.length === 0 ? ["plans: none in the file"] : inputs.wanted.map((plan2) => `plan ${plan2.sku}: ${plan2.displayName}`),
28221
+ // A contact-us plan with a price in the file waits to be sold (the full template's example, API-124).
28222
+ ...inputs.filePlans.filter(
28223
+ (plan2) => plan2.pricingModel === "custom" && typeof plan2.priceAmount === "number" && plan2.priceAmount > 0
28224
+ ).map(
28225
+ (plan2) => `plan ${plan2.sku}: "contact us" for now. On a Starter or Professional project, sell it at ${plan2.priceAmount} ${(plan2.priceCurrency ?? "usd").toUpperCase()} a ${plan2.billingInterval ?? "month"} with: apitogo config --plan-sku ${plan2.sku} --plan-price ${plan2.priceAmount}`
28226
+ ),
27745
28227
  ...archiving.map(
27746
28228
  (sku) => `plan ${sku}: will archive (no longer in the plans file: it leaves the pricing page and takes no new subscribers; current subscribers keep access)`
27747
28229
  ),
@@ -27941,7 +28423,7 @@ async function planPortalService(resolved, ledger) {
27941
28423
  const hash2 = await portalBuildHash(resolved, prepared);
27942
28424
  const previous = stepOf(ledger, "portal", "build");
27943
28425
  const built = await exists5(
27944
- path80.join(resolved.state.buildDir, "index.html")
28426
+ path81.join(resolved.state.buildDir, "index.html")
27945
28427
  );
27946
28428
  if (previous?.status === "succeeded" && previous.hash === hash2 && built) {
27947
28429
  return {
@@ -28327,7 +28809,7 @@ async function statusV2(dir, only, options = {}) {
28327
28809
  if (service === "portal") {
28328
28810
  const build2 = stepOf(ledger, "portal", "build");
28329
28811
  const built = await exists5(
28330
- path80.join(resolved.state.buildDir, "index.html")
28812
+ path81.join(resolved.state.buildDir, "index.html")
28331
28813
  );
28332
28814
  const state = build2?.status === "failed" ? "build-failed" : build2?.status === "succeeded" && built ? "built" : "not-built";
28333
28815
  services.push({
@@ -28338,7 +28820,7 @@ async function statusV2(dir, only, options = {}) {
28338
28820
  ...build2?.finishedAt ? [`last build: ${build2.finishedAt}`] : [],
28339
28821
  ...build2?.status === "failed" ? [`error: ${build2.error ?? "unknown"}`] : [],
28340
28822
  ...state === "built" ? [
28341
- `output: ${path80.relative(resolved.configDir, resolved.state.buildDir)}`
28823
+ `output: ${path81.relative(resolved.configDir, resolved.state.buildDir)}`
28342
28824
  ] : [],
28343
28825
  ...url2 ? [`url: ${url2}`] : ["url: not known (portal is not deployed yet)"],
28344
28826
  ...await hostingLine(resolved, manifest, options.hostingRemote)
@@ -28405,7 +28887,7 @@ function formatStatus(output4) {
28405
28887
 
28406
28888
  // src/cli/deploy/static-web-config.ts
28407
28889
  import { access as access10, writeFile as writeFile22 } from "node:fs/promises";
28408
- import path81 from "node:path";
28890
+ import path82 from "node:path";
28409
28891
  var STATIC_WEB_CONFIG_FILE = "staticwebapp.config.json";
28410
28892
  var STATIC_WEB_CONFIG_VERSION = 3;
28411
28893
  var CLIENT_ONLY_ROUTES = [
@@ -28423,7 +28905,7 @@ var DEFAULT_STATIC_WEB_CONFIG = {
28423
28905
  }
28424
28906
  };
28425
28907
  async function ensureStaticWebConfig(buildDir) {
28426
- const file = path81.join(buildDir, STATIC_WEB_CONFIG_FILE);
28908
+ const file = path82.join(buildDir, STATIC_WEB_CONFIG_FILE);
28427
28909
  try {
28428
28910
  await access10(file);
28429
28911
  return false;
@@ -28680,7 +29162,8 @@ async function deployPlansService(options, deps, projectId, named) {
28680
29162
  steps: result.steps,
28681
29163
  url: result.apiBaseUrl,
28682
29164
  summary: `${planCount} plan${planCount === 1 ? "" : "s"}${billing && billing !== "none" ? `, billing: ${billing}` : ""}`,
28683
- ...result.notes ? { notes: result.notes } : {}
29165
+ ...result.notes ? { notes: result.notes } : {},
29166
+ ...result.stripeWebhook ? { stripeWebhook: result.stripeWebhook } : {}
28684
29167
  };
28685
29168
  }
28686
29169
  async function recordPortalAddressBeforeBuild(dir, deps, projectId) {
@@ -28777,7 +29260,7 @@ function formatDeploy(output4) {
28777
29260
  // src/cli/deploy/command.ts
28778
29261
  async function ensureLinkedProject(argv, deps) {
28779
29262
  if (argv.projectId?.trim() || deps.envProjectId?.trim()) return true;
28780
- const dir = path82.resolve(process.cwd(), argv.dir);
29263
+ const dir = path83.resolve(process.cwd(), argv.dir);
28781
29264
  let linked;
28782
29265
  try {
28783
29266
  linked = Boolean(loadConfigV2(dir).resolved.project.id);
@@ -28827,26 +29310,37 @@ async function deployProject(argv) {
28827
29310
  );
28828
29311
  try {
28829
29312
  const config2 = loadConfig();
28830
- const output4 = await deployV2(
29313
+ const client = new PlatformApiClient(config2);
29314
+ const dir = path83.resolve(process.cwd(), argv.dir);
29315
+ const deploy = (only) => deployV2(
28831
29316
  {
28832
- dir: path82.resolve(process.cwd(), argv.dir),
28833
- only: Array.isArray(argv.only) ? argv.only.join(",") : argv.only,
29317
+ dir,
29318
+ only: only ?? (Array.isArray(argv.only) ? argv.only.join(",") : argv.only),
28834
29319
  projectId: argv.projectId,
28835
29320
  env: argv.env,
28836
29321
  force: argv.force === true,
28837
29322
  yes: argv.yes === true,
28838
29323
  jsonOnly: quiet
28839
29324
  },
28840
- {
28841
- client: new PlatformApiClient(config2),
28842
- envProjectId: config2.defaultProjectId
28843
- }
29325
+ { client, envProjectId: config2.defaultProjectId }
28844
29326
  );
29327
+ const output4 = await deploy();
28845
29328
  if (json) {
28846
29329
  console.log(JSON.stringify({ status: "succeeded", ...output4 }, null, 2));
28847
29330
  return;
28848
29331
  }
28849
29332
  for (const line2 of formatDeploy(output4)) printDiagnosticsToConsole(line2);
29333
+ const webhook = await offerStripeWebhookSecret(output4, {
29334
+ interactive,
29335
+ client: () => client,
29336
+ askHidden: (prompt) => readHidden(prompt),
29337
+ print: printDiagnosticsToConsole,
29338
+ wireConfig: () => wireKnownSecret({ action: "set", dir }, STRIPE_WEBHOOK_SECRET),
29339
+ redeployPlans: () => deploy("plans")
29340
+ });
29341
+ if (webhook.status === "saved")
29342
+ for (const line2 of formatDeploy(webhook.output))
29343
+ printDiagnosticsToConsole(line2);
28850
29344
  printResultToConsole("Deploy finished.");
28851
29345
  } catch (error) {
28852
29346
  await reportFailure(error, json);
@@ -28925,7 +29419,7 @@ var deploy_default = {
28925
29419
  }),
28926
29420
  handler: async (argv) => {
28927
29421
  await captureEvent({ argv, event: "apitogo deploy" });
28928
- if (await isConfigV2(path83.resolve(process.cwd(), argv.dir))) {
29422
+ if (await isConfigV2(path84.resolve(process.cwd(), argv.dir))) {
28929
29423
  await deployProject(argv);
28930
29424
  return;
28931
29425
  }
@@ -28935,7 +29429,7 @@ var deploy_default = {
28935
29429
 
28936
29430
  // src/cli/export/handler.ts
28937
29431
  import { mkdir as mkdir19, writeFile as writeFile23 } from "node:fs/promises";
28938
- import path84 from "node:path";
29432
+ import path85 from "node:path";
28939
29433
  init_invariant();
28940
29434
  var ExportError = class extends ApitogoError {
28941
29435
  constructor(message, developerHint) {
@@ -28949,7 +29443,7 @@ async function exportOpenApi(args, deps) {
28949
29443
  `Cannot export "${args.target}".`,
28950
29444
  "Only `apitogo export openapi` exists."
28951
29445
  );
28952
- const dir = path84.resolve(process.cwd(), args.dir ?? ".");
29446
+ const dir = path85.resolve(process.cwd(), args.dir ?? ".");
28953
29447
  if (!await isConfigV2(dir))
28954
29448
  throw new ExportError(
28955
29449
  "This command needs a config version 2 project.",
@@ -28974,8 +29468,8 @@ async function exportOpenApi(args, deps) {
28974
29468
  throw error;
28975
29469
  }
28976
29470
  if (!args.out) return { text: text2 };
28977
- const file = path84.resolve(process.cwd(), args.out);
28978
- await mkdir19(path84.dirname(file), { recursive: true });
29471
+ const file = path85.resolve(process.cwd(), args.out);
29472
+ await mkdir19(path85.dirname(file), { recursive: true });
28979
29473
  await writeFile23(file, text2);
28980
29474
  return { text: text2, file };
28981
29475
  }
@@ -29251,7 +29745,7 @@ var logout_default = {
29251
29745
  };
29252
29746
 
29253
29747
  // src/cli/portal/commands.ts
29254
- import path85 from "node:path";
29748
+ import path86 from "node:path";
29255
29749
  init_invariant();
29256
29750
  function parseOnly(value) {
29257
29751
  if (value === void 0) return void 0;
@@ -29268,7 +29762,7 @@ function parseOnly(value) {
29268
29762
  async function run2(command2, argv, compute, format) {
29269
29763
  const json = argv.json === true;
29270
29764
  try {
29271
- const dir = path85.resolve(process.cwd(), argv.dir ?? ".");
29765
+ const dir = path86.resolve(process.cwd(), argv.dir ?? ".");
29272
29766
  if (!await isConfigV2(dir)) {
29273
29767
  throw new ServiceEditError(
29274
29768
  `${command2} works with a config version 2 project, and there is none in ${dir}.`,
@@ -29493,14 +29987,14 @@ var remove_default = {
29493
29987
 
29494
29988
  // src/cli/dev/handler.ts
29495
29989
  init_joinUrl();
29496
- import path88 from "node:path";
29990
+ import path89 from "node:path";
29497
29991
 
29498
29992
  // src/vite/dev-server.ts
29499
29993
  init_logger();
29500
29994
  import fs11 from "node:fs/promises";
29501
29995
  import http from "node:http";
29502
29996
  import https from "node:https";
29503
- import path87 from "node:path";
29997
+ import path88 from "node:path";
29504
29998
  import { createHttpTerminator } from "http-terminator";
29505
29999
  import {
29506
30000
  createServer as createViteServer,
@@ -29533,7 +30027,7 @@ init_loader();
29533
30027
  // src/vite/pagefind-dev-index.ts
29534
30028
  init_invariant();
29535
30029
  init_joinUrl();
29536
- import path86 from "node:path";
30030
+ import path87 from "node:path";
29537
30031
  import { createIndex as createIndex2 } from "pagefind";
29538
30032
  import { isRunnableDevEnvironment } from "vite";
29539
30033
  async function* buildPagefindDevIndex(vite, config2) {
@@ -29586,7 +30080,7 @@ async function* buildPagefindDevIndex(vite, config2) {
29586
30080
  path: urlPath
29587
30081
  };
29588
30082
  }
29589
- const outputPath = path86.join(vite.config.publicDir, "pagefind");
30083
+ const outputPath = path87.join(vite.config.publicDir, "pagefind");
29590
30084
  await pagefindIndex.writeFiles({ outputPath });
29591
30085
  yield { type: "complete", success: true, indexed };
29592
30086
  }
@@ -29605,9 +30099,9 @@ var DevServer = class {
29605
30099
  this.protocol = "https";
29606
30100
  const { dir } = this.options;
29607
30101
  const [key, cert, ca] = await Promise.all([
29608
- fs11.readFile(path87.resolve(dir, config2.https.key)),
29609
- fs11.readFile(path87.resolve(dir, config2.https.cert)),
29610
- config2.https.ca ? fs11.readFile(path87.resolve(dir, config2.https.ca)) : void 0
30102
+ fs11.readFile(path88.resolve(dir, config2.https.key)),
30103
+ fs11.readFile(path88.resolve(dir, config2.https.cert)),
30104
+ config2.https.ca ? fs11.readFile(path88.resolve(dir, config2.https.ca)) : void 0
29611
30105
  ]);
29612
30106
  return https.createServer({ key, cert, ca });
29613
30107
  }
@@ -29624,7 +30118,7 @@ var DevServer = class {
29624
30118
  );
29625
30119
  const server = await this.createNodeServer(config2);
29626
30120
  if (config2.search?.type === "pagefind" && viteConfig.publicDir !== false) {
29627
- const publicRoot = path87.resolve(
30121
+ const publicRoot = path88.resolve(
29628
30122
  this.options.dir,
29629
30123
  typeof viteConfig.publicDir === "string" ? viteConfig.publicDir : "public"
29630
30124
  );
@@ -29641,7 +30135,7 @@ var DevServer = class {
29641
30135
  // built-in transform middleware which would treat the path as a static asset.
29642
30136
  name: "apitogo:entry-client",
29643
30137
  configureServer(server2) {
29644
- const entryPath = path87.posix.join(
30138
+ const entryPath = path88.posix.join(
29645
30139
  server2.config.base,
29646
30140
  "/__z/entry.client.tsx"
29647
30141
  );
@@ -29806,7 +30300,7 @@ init_package_json();
29806
30300
  async function dev(argv) {
29807
30301
  const packageJson3 = getZudokuPackageJson();
29808
30302
  process.env.NODE_ENV = "development";
29809
- let dir = path88.resolve(process.cwd(), argv.dir);
30303
+ let dir = path89.resolve(process.cwd(), argv.dir);
29810
30304
  if (await isConfigV2(dir)) {
29811
30305
  const { root, prepared } = await preparePortalWorkspace(dir, "development");
29812
30306
  dir = root;
@@ -29904,142 +30398,6 @@ var run_default = {
29904
30398
  }
29905
30399
  };
29906
30400
 
29907
- // src/cli/secrets/handler.ts
29908
- import path89 from "node:path";
29909
- init_invariant();
29910
- var SECRET_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
29911
- var SecretsError = class extends ApitogoError {
29912
- constructor(message, developerHint) {
29913
- super(message, { developerHint, title: "Cannot change secrets" });
29914
- this.name = "SecretsError";
29915
- }
29916
- };
29917
- async function resolveProjectId2(argv, deps) {
29918
- const explicit = argv.projectId?.trim();
29919
- if (explicit) return explicit;
29920
- const dir = path89.resolve(process.cwd(), argv.dir ?? ".");
29921
- if (await isConfigV2(dir)) {
29922
- const id = loadConfigV2(dir).resolved.project.id;
29923
- if (id) return id;
29924
- }
29925
- if (deps.envProjectId?.trim()) return deps.envProjectId.trim();
29926
- throw new SecretsError(
29927
- "This folder is not linked to a project yet.",
29928
- "Pass --project-id <id>, run `apitogo deploy` once, or set APITOGO_DEFAULT_PROJECT_ID."
29929
- );
29930
- }
29931
- function requireName(name, action) {
29932
- if (!name) {
29933
- throw new SecretsError(
29934
- `secrets ${action} needs a name.`,
29935
- `For example: apitogo secrets ${action} STRIPE_API_KEY`
29936
- );
29937
- }
29938
- if (!SECRET_NAME.test(name)) {
29939
- throw new SecretsError(
29940
- `"${name}" is not a valid secret name.`,
29941
- "Use letters, digits and underscores, starting with a letter or underscore (at most 128 characters)."
29942
- );
29943
- }
29944
- return name;
29945
- }
29946
- async function runSecrets(argv, deps) {
29947
- if (!["set", "list", "rm"].includes(argv.action)) {
29948
- throw new SecretsError(
29949
- `Unknown action: ${argv.action}.`,
29950
- "Use set, list or rm."
29951
- );
29952
- }
29953
- if (argv.action === "list") {
29954
- const projectId2 = await resolveProjectId2(argv, deps);
29955
- const { secrets: secrets2 } = await deps.client.listSecrets(projectId2);
29956
- return { action: "list", projectId: projectId2, secrets: secrets2 };
29957
- }
29958
- const name = requireName(argv.name, argv.action);
29959
- const projectId = await resolveProjectId2(argv, deps);
29960
- if (argv.action === "rm") {
29961
- if (argv.yes !== true) {
29962
- if (!deps.isTerminal)
29963
- throw new SecretsError(
29964
- "This is not an interactive terminal.",
29965
- "Pass --yes to remove the secret."
29966
- );
29967
- if (!await deps.confirm(`Remove secret ${name}? (y/N) `))
29968
- throw new SecretsError("Cancelled. Nothing was removed.");
29969
- }
29970
- await deps.client.deleteSecret(projectId, name);
29971
- return { action: "rm", projectId, name };
29972
- }
29973
- let value;
29974
- if (deps.isTerminal) {
29975
- value = await deps.askValue(`Value for ${name} (hidden): `);
29976
- const again = await deps.askValue("Type it again: ");
29977
- if (again !== value)
29978
- throw new SecretsError(
29979
- "The two values are not the same. Nothing was saved."
29980
- );
29981
- } else {
29982
- value = await deps.readPiped();
29983
- }
29984
- if (value.length === 0)
29985
- throw new SecretsError("The value is empty. Nothing was saved.");
29986
- const result = await deps.client.setSecret(projectId, name, value);
29987
- return {
29988
- action: "set",
29989
- projectId,
29990
- name: result.name,
29991
- created: result.created
29992
- };
29993
- }
29994
- function formatSecrets(result) {
29995
- if (result.action === "list") {
29996
- return result.secrets.length === 0 ? ["No secrets are set for this project."] : result.secrets.map((s) => `${s.name} (updated ${s.updatedAtUtc})`);
29997
- }
29998
- if (result.action === "set") {
29999
- return [
30000
- `Secret ${result.name} ${result.created ? "created" : "replaced"}.`
30001
- ];
30002
- }
30003
- return [`Secret ${result.name} removed.`];
30004
- }
30005
- async function secrets(argv) {
30006
- const json = argv.json === true;
30007
- try {
30008
- const config2 = loadConfig();
30009
- const result = await runSecrets(argv, {
30010
- client: new PlatformApiClient(config2),
30011
- envProjectId: config2.defaultProjectId,
30012
- isTerminal: process.stdin.isTTY === true && !json,
30013
- askValue: (prompt) => readHidden(prompt),
30014
- readPiped: () => readStdin(),
30015
- confirm
30016
- });
30017
- if (json) {
30018
- console.log(JSON.stringify({ status: "succeeded", ...result }, null, 2));
30019
- return;
30020
- }
30021
- for (const line2 of formatSecrets(result)) printDiagnosticsToConsole(line2);
30022
- if (result.action !== "list") printResultToConsole("Done.");
30023
- } catch (error) {
30024
- const message = error instanceof Error ? error.message : String(error);
30025
- const hint = error instanceof ApitogoError ? error.developerHint : void 0;
30026
- if (json) {
30027
- console.log(
30028
- JSON.stringify({
30029
- status: "failed",
30030
- error: message,
30031
- hint: hint ?? null
30032
- })
30033
- );
30034
- process.exitCode = 1;
30035
- return;
30036
- }
30037
- await printCriticalFailureToConsoleAndExit(
30038
- hint ? `${message} ${hint}` : message
30039
- );
30040
- }
30041
- }
30042
-
30043
30401
  // src/cli/cmds/secrets.ts
30044
30402
  var secrets_default = {
30045
30403
  desc: "Set, list or remove the project's secrets (values are hidden, encrypted on the platform and never shown again)",