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