create-ailk 0.25.1 → 0.27.0
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/README.md +14 -5
- package/dist/lib/fetch-template-snapshot.d.ts +2 -1
- package/dist/lib/fetch-template-snapshot.js +6 -5
- package/dist/lib/module-license-gate.js +2 -3
- package/dist/lib/module-tier.d.ts +2 -8
- package/dist/lib/module-tier.js +13 -12
- package/dist/module-architecture.js +14 -2
- package/dist/starter-grid.d.ts +6 -6
- package/dist/starter-grid.js +12 -5
- package/dist/surfaces.js +36 -2
- package/dist/template-record.d.ts +11 -3
- package/dist/template-record.js +20 -7
- package/dist/topology.js +23 -4
- package/dist/update.d.ts +1 -1
- package/dist/update.js +240 -20
- package/package.json +3 -3
- package/release-scope.json +1 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +8 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +2 -2
- package/site-templates/marketing-site/ailk-template.json +7 -1
- package/site-templates/marketing-site/provenance.json +7 -1
- package/site-templates/saas-product/ailk-template.json +2 -1
- package/site-templates/saas-product/provenance.json +1 -0
- package/templates/.env.example +22 -0
- package/templates/CLAUDE.md +20 -20
- package/templates/README.md +19 -19
- package/templates/apps/api/.env.example +28 -0
- package/templates/apps/api/CLAUDE.md +35 -9
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
- package/templates/apps/api/vercel.json +7 -1
- package/templates/apps/mcp/CLAUDE.md +27 -10
- package/templates/apps/web/.env.example +13 -0
- package/templates/apps/web/CLAUDE.md +34 -6
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
- package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +168 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +22 -39
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +301 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -18
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
- package/templates/apps/web/app/[locale]/layout.tsx +48 -13
- package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
- package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +118 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +14 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
- package/templates/apps/web/auth.ts +8 -4
- package/templates/apps/web/i18n/request.ts +49 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +289 -1
- package/templates/apps/web/lib/auth-email.ts +102 -20
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +92 -23
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +97 -70
- package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
- package/templates/apps/web/lib/relay.ts +116 -1
- package/templates/apps/web/lib/sign-up-agreements.ts +302 -28
- package/templates/apps/web/lib/site-contributions.ts +18 -0
- package/templates/apps/web/messages/en.json +271 -20
- package/templates/apps/web/messages/site/en.json +1 -0
- package/templates/apps/web/middleware.ts +8 -1
- package/templates/apps/web/next.config.mjs +9 -1
- package/templates/apps/web/package.json +2 -1
- package/versions.json +48 -48
|
@@ -10,25 +10,36 @@
|
|
|
10
10
|
* route and starts a flow the visitor did not ask for.
|
|
11
11
|
*
|
|
12
12
|
* #8790 — the gate reads as one of the site's inner pages, not a bare
|
|
13
|
-
* full-height stop: the
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
13
|
+
* full-height stop: the back arrow at the start edge (`NavBackLink`), then the
|
|
14
|
+
* card. The document title is the account page's job (`generateMetadata`
|
|
15
|
+
* there reads the session and names this state "Sign in · <site>").
|
|
16
|
+
*
|
|
17
|
+
* The one card (#9662, operator 2026-10-24): the single hub action sits in
|
|
18
|
+
* `AuthCard`'s frame — the site's mark in the 48px tile, "Sign in to <site>"
|
|
19
|
+
* (`SITE_NAME`, the deployment's own name, never the request's), the
|
|
20
|
+
* "Don't have an account? Sign up" line to this site's own sign-in route with
|
|
21
|
+
* `prompt=create`, which lands on the hub's sign-up screen and returns here
|
|
22
|
+
* (#9912 D1), and the Terms + Privacy acknowledgement on this site's own
|
|
23
|
+
* policy routes. A site that fills `account.gate.prospectPrompt` +
|
|
24
|
+
* `prospectLink` + `prospectHref` replaces the toggle line with its own. The
|
|
25
|
+
* acknowledgement is built here rather than from the `(auth)` group's
|
|
26
|
+
* builder: a spoke carries no `(auth)` group (create-ailk's
|
|
27
|
+
* `SPOKE_REMOVAL_SEEDS`).
|
|
21
28
|
*
|
|
22
29
|
* Not configured (`hubSessionConfig()` is `null`: a client id, secret or
|
|
23
|
-
* session secret missing),
|
|
24
|
-
*
|
|
30
|
+
* session secret missing), the card says "sign-in isn't available yet" and
|
|
31
|
+
* its one action leads back to the site.
|
|
25
32
|
*/
|
|
26
33
|
import { localeHref } from "@working-theory/ui";
|
|
34
|
+
import { AuthCard } from "@working-theory/ui/blocks";
|
|
27
35
|
import { NavBackLink } from "@working-theory/ui/chrome";
|
|
28
|
-
import { Button,
|
|
36
|
+
import { Button, Link, Text } from "@working-theory/ui/primitives";
|
|
29
37
|
import { getTranslations } from "next-intl/server";
|
|
30
38
|
import type { ReactElement } from "react";
|
|
39
|
+
|
|
40
|
+
import { SITE_NAME } from "../site-brand";
|
|
31
41
|
import { siteLogo } from "../site-logo";
|
|
42
|
+
|
|
32
43
|
import { hubSessionConfig } from "./config";
|
|
33
44
|
import { HUB_SESSION_FALLBACK_PATH } from "./session";
|
|
34
45
|
|
|
@@ -46,89 +57,105 @@ export async function HubSignInGate({
|
|
|
46
57
|
requestHeaders,
|
|
47
58
|
}: HubSignInGateProps): Promise<ReactElement> {
|
|
48
59
|
const t = await getTranslations({ locale, namespace: "account.gate" });
|
|
49
|
-
const
|
|
60
|
+
const signIn = await getTranslations({ locale, namespace: "signIn" });
|
|
61
|
+
const config = hubSessionConfig();
|
|
50
62
|
const next =
|
|
51
63
|
requestHeaders.get("x-pathname") ??
|
|
52
64
|
localeHref(locale, HUB_SESSION_FALLBACK_PATH);
|
|
65
|
+
const signInHref = `${HUB_SIGN_IN_ROUTE}?next=${encodeURIComponent(next)}`;
|
|
53
66
|
const home = localeHref(locale, "/");
|
|
54
67
|
// The three prospect keys are optional extensions of the typed catalog (the
|
|
55
68
|
// shipped en.json carries none — every leaf must be a non-empty string), so
|
|
56
69
|
// presence is the switch; hence the cast on `has`.
|
|
57
70
|
type Key = Parameters<typeof t.has>[0];
|
|
58
71
|
const has = (key: string): boolean => t.has(key as Key);
|
|
59
|
-
|
|
60
|
-
|
|
72
|
+
// Only a configured gate offers a way in; the not-configured state has none.
|
|
73
|
+
const prospect = !config
|
|
74
|
+
? undefined
|
|
75
|
+
: has("prospectPrompt") && has("prospectLink") && has("prospectHref")
|
|
61
76
|
? {
|
|
62
77
|
prompt: t("prospectPrompt" as Key),
|
|
63
78
|
linkLabel: t("prospectLink" as Key),
|
|
64
79
|
href: t("prospectHref" as Key),
|
|
65
80
|
}
|
|
66
|
-
:
|
|
81
|
+
: {
|
|
82
|
+
prompt: signIn("prospectPrompt"),
|
|
83
|
+
linkLabel: signIn("prospectLink"),
|
|
84
|
+
href: `${signInHref}&prompt=create`,
|
|
85
|
+
};
|
|
67
86
|
|
|
68
87
|
return (
|
|
69
88
|
<div
|
|
70
89
|
className="flex min-h-screen flex-col"
|
|
71
90
|
data-testid="account-sign-in-gate"
|
|
72
91
|
>
|
|
73
|
-
{/* The site's inner-page header: back arrow at the start
|
|
92
|
+
{/* The site's inner-page header: the back arrow at the start (#8790 AC-1); the card carries the mark (#9662). */}
|
|
74
93
|
<header
|
|
75
|
-
className="
|
|
94
|
+
className="flex h-16 items-center px-5 md:px-8"
|
|
76
95
|
data-testid="account-sign-in-gate-header"
|
|
77
96
|
>
|
|
78
|
-
<
|
|
79
|
-
<NavBackLink href={home} label={t("homeLink")} />
|
|
80
|
-
</div>
|
|
81
|
-
<Link
|
|
82
|
-
href={home}
|
|
83
|
-
variant="unstyled"
|
|
84
|
-
className="inline-flex h-7 items-center [&>img]:h-full [&>img]:w-auto [&>img]:object-contain"
|
|
85
|
-
data-testid="account-sign-in-gate-mark"
|
|
86
|
-
>
|
|
87
|
-
{siteLogo()}
|
|
88
|
-
</Link>
|
|
89
|
-
<div />
|
|
97
|
+
<NavBackLink href={home} label={t("homeLink")} />
|
|
90
98
|
</header>
|
|
91
|
-
<div className="flex flex-1 flex-col items-center justify-center
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
99
|
+
<div className="flex flex-1 flex-col items-center md:justify-center md:py-section">
|
|
100
|
+
{/* AuthShell's 400px column: the cap sits on the wrapper, never on the card (AuthShell.tsx). */}
|
|
101
|
+
<div className="w-full max-w-100">
|
|
102
|
+
<AuthCard
|
|
103
|
+
intent="sign-in"
|
|
104
|
+
footer="none"
|
|
105
|
+
// The page's one heading (#7894): the gate is the whole page.
|
|
106
|
+
headingLevel={1}
|
|
107
|
+
logo={siteLogo()}
|
|
108
|
+
labels={{
|
|
109
|
+
signInHeading: config
|
|
110
|
+
? t("hubHeadline", { site: SITE_NAME })
|
|
111
|
+
: t("emptyHeadline"),
|
|
112
|
+
}}
|
|
113
|
+
subhead={config ? t("hubDescription") : t("emptyDescription")}
|
|
114
|
+
{...(prospect ? { prospect } : {})}
|
|
115
|
+
body={
|
|
116
|
+
config ? (
|
|
117
|
+
// The primary action: full width at `big` (44px).
|
|
118
|
+
<Button asChild size="big" className="w-full">
|
|
119
|
+
<Link
|
|
120
|
+
href={signInHref}
|
|
121
|
+
variant="bare"
|
|
122
|
+
data-testid="account-hub-sign-in"
|
|
123
|
+
>
|
|
124
|
+
{t("hubSignIn")}
|
|
125
|
+
</Link>
|
|
126
|
+
</Button>
|
|
127
|
+
) : (
|
|
128
|
+
<Button
|
|
129
|
+
asChild
|
|
130
|
+
variant="secondary"
|
|
131
|
+
size="big"
|
|
132
|
+
className="w-full"
|
|
133
|
+
>
|
|
134
|
+
<Link
|
|
135
|
+
href={home}
|
|
136
|
+
variant="bare"
|
|
137
|
+
data-testid="account-sign-in-gate-home"
|
|
138
|
+
>
|
|
139
|
+
{t("homeLink")}
|
|
140
|
+
</Link>
|
|
141
|
+
</Button>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
footnote={
|
|
145
|
+
<Text as="p" variant="caption">
|
|
146
|
+
{signIn("acknowledgement")}{" "}
|
|
147
|
+
<Link variant="inline" href={localeHref(locale, "/terms")}>
|
|
148
|
+
{signIn("termsLink")}
|
|
149
|
+
</Link>{" "}
|
|
150
|
+
{signIn("acknowledgementAnd")}{" "}
|
|
151
|
+
<Link variant="inline" href={localeHref(locale, "/privacy")}>
|
|
152
|
+
{signIn("privacyLink")}
|
|
153
|
+
</Link>
|
|
154
|
+
{signIn("acknowledgementEnd")}
|
|
155
|
+
</Text>
|
|
156
|
+
}
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
132
159
|
</div>
|
|
133
160
|
</div>
|
|
134
161
|
);
|
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
* `lib/sign-up-agreements.ts`'s account hook writes the new user's `sign_up`
|
|
15
15
|
* consent grants through `consentGrant.createMany` on the transaction client
|
|
16
16
|
* Better Auth's sign-up transaction runs on — only rows for the user that
|
|
17
|
-
* transaction is creating, never a read and never any other row.
|
|
18
|
-
*
|
|
17
|
+
* transaction is creating, never a read and never any other row. A
|
|
18
|
+
* magic-link sign-up (#9667 D5) writes the same rows from the session hook,
|
|
19
|
+
* on the transaction the adapter opens for them, for the user that verify
|
|
20
|
+
* just created. It imports nothing from here; it reaches the client through
|
|
21
|
+
* the adapter.
|
|
19
22
|
*
|
|
20
23
|
* Every other business-logic caller goes through apps/api, never the
|
|
21
24
|
* database directly (architecture.yaml `apps/web` may_not_import_from
|
|
@@ -35,12 +35,20 @@
|
|
|
35
35
|
* and only as strong as the platform: it holds only where the site's edge
|
|
36
36
|
* overwrites `x-forwarded-for` and `x-real-ip` (Vercel does; a bare
|
|
37
37
|
* self-hosted Next server does not).
|
|
38
|
+
* - A BOT CHECK ON THE BOOKING (#9889). `POST /v1/schedule/book` asks
|
|
39
|
+
* Vercel BotID (`checkBotId`) for a verdict after every refusal above and
|
|
40
|
+
* before the hub is called. Only a human verdict forwards; a bot is
|
|
41
|
+
* `403 bot_detected`, and a check that throws, answers no boolean or
|
|
42
|
+
* takes over 5 seconds is `503 bot_check_unavailable` — either way the
|
|
43
|
+
* hub is never called. It runs only on Vercel (`VERCEL=1`), unless
|
|
44
|
+
* `AILK_BOOKING_BOT_CHECK=off`.
|
|
38
45
|
*
|
|
39
46
|
* NO SECRET IS LOGGED. A failure logs `{ path, outcome }` and nothing else.
|
|
40
47
|
*/
|
|
41
48
|
import { isIP } from "node:net";
|
|
42
49
|
|
|
43
50
|
import { relayToApi, visitorForwardHeaders } from "@working-theory/api-client";
|
|
51
|
+
import { checkBotId } from "botid/server";
|
|
44
52
|
|
|
45
53
|
import { browserApiOrigin } from "./api-origin.generated";
|
|
46
54
|
import { readConsentVisitorFromCookieHeader } from "./consent-visitor";
|
|
@@ -69,15 +77,92 @@ function route(method: RelayMethod, path: string): RelayRoute {
|
|
|
69
77
|
*/
|
|
70
78
|
const SESSION_ROUTE = route("POST", "/v1/commerce/invoices");
|
|
71
79
|
|
|
80
|
+
/** The booking pair, the one route the bot check guards (#9889 D1). */
|
|
81
|
+
const BOOK_ROUTE = route("POST", "/v1/schedule/book");
|
|
82
|
+
|
|
72
83
|
export const RELAY_ROUTES: readonly RelayRoute[] = Object.freeze([
|
|
73
84
|
route("POST", "/v1/leads/submit"),
|
|
74
85
|
route("POST", "/v1/leads/subscribe"),
|
|
75
86
|
route("POST", "/v1/leads/callback"),
|
|
76
87
|
route("GET", "/v1/schedule/availability"),
|
|
77
|
-
|
|
88
|
+
BOOK_ROUTE,
|
|
78
89
|
SESSION_ROUTE,
|
|
79
90
|
]);
|
|
80
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The entries the bot check guards (#9889 D1, D4). The locale layout passes
|
|
94
|
+
* the same list to `BotIdClient`, so the browser protects exactly the pairs
|
|
95
|
+
* the server checks: a checked pair the client does not protect would carry
|
|
96
|
+
* no challenge header and be refused as a bot.
|
|
97
|
+
*/
|
|
98
|
+
export const BOT_CHECKED_ROUTES: readonly RelayRoute[] = Object.freeze([
|
|
99
|
+
BOOK_ROUTE,
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
/** The longest the relay waits for a verdict; the library's own fetch has no bound. */
|
|
103
|
+
const BOT_CHECK_TIMEOUT_MS = 5_000;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* True when the booking bot check runs (#9889 D2): on Vercel, unless
|
|
107
|
+
* `AILK_BOOKING_BOT_CHECK` is `off`. Unset, empty or any other value leaves
|
|
108
|
+
* it on, so a typo fails toward protection. Read on every call, never cached.
|
|
109
|
+
*/
|
|
110
|
+
export function bookingBotCheckOn(): boolean {
|
|
111
|
+
return process.env.VERCEL === "1" && !botCheckSwitchedOff();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function botCheckSwitchedOff(): boolean {
|
|
115
|
+
return process.env.AILK_BOOKING_BOT_CHECK?.trim().toLowerCase() === "off";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let warnedBotCheckInactive = false;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Called when the check is off. Off Vercel it cannot run (D2): a production
|
|
122
|
+
* site whose switch is not `off` logs that once per process, so an owner who
|
|
123
|
+
* believes it is on can find out. It never refuses.
|
|
124
|
+
*/
|
|
125
|
+
function warnBotCheckInactiveOnce(): void {
|
|
126
|
+
if (warnedBotCheckInactive || process.env.NODE_ENV !== "production") return;
|
|
127
|
+
if (botCheckSwitchedOff()) return;
|
|
128
|
+
warnedBotCheckInactive = true;
|
|
129
|
+
console.warn("[relay] booking bot check inactive", {
|
|
130
|
+
outcome: "not-on-vercel",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function namedError(name: string): Error {
|
|
135
|
+
const err = new Error(name);
|
|
136
|
+
err.name = name;
|
|
137
|
+
return err;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* BotID's verdict on this request (D1, D3): `true` for a bot, `false` for a
|
|
142
|
+
* human. Throws `TimeoutError` past `BOT_CHECK_TIMEOUT_MS` and
|
|
143
|
+
* `InvalidVerdictError` when `isBot` is not a boolean, as `checkBotId` returns
|
|
144
|
+
* for an error answer from Vercel.
|
|
145
|
+
*/
|
|
146
|
+
async function isBotVerdict(): Promise<boolean> {
|
|
147
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
148
|
+
try {
|
|
149
|
+
const verdict = await Promise.race([
|
|
150
|
+
checkBotId(),
|
|
151
|
+
new Promise<never>((_resolve, reject) => {
|
|
152
|
+
timer = setTimeout(
|
|
153
|
+
() => reject(namedError("TimeoutError")),
|
|
154
|
+
BOT_CHECK_TIMEOUT_MS,
|
|
155
|
+
);
|
|
156
|
+
}),
|
|
157
|
+
]);
|
|
158
|
+
const isBot = (verdict as { isBot?: unknown } | undefined)?.isBot;
|
|
159
|
+
if (typeof isBot !== "boolean") throw namedError("InvalidVerdictError");
|
|
160
|
+
return isBot;
|
|
161
|
+
} finally {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
81
166
|
/**
|
|
82
167
|
* The three lead routes (#8438 D11, D12): a visitor's banner choice is
|
|
83
168
|
* linked to a Person only through these — a booking has no consent writer.
|
|
@@ -466,6 +551,36 @@ export async function handleRelay(request: Request): Promise<Response> {
|
|
|
466
551
|
return refusal(502, "relay_unreachable", "The site could not reach its API");
|
|
467
552
|
}
|
|
468
553
|
|
|
554
|
+
if (BOT_CHECKED_ROUTES.includes(entry)) {
|
|
555
|
+
if (bookingBotCheckOn()) {
|
|
556
|
+
let isBot: boolean;
|
|
557
|
+
try {
|
|
558
|
+
isBot = await isBotVerdict();
|
|
559
|
+
} catch (err) {
|
|
560
|
+
// The error's name only — never its message (#9889 D1).
|
|
561
|
+
console.error("[relay] bot check failed", {
|
|
562
|
+
path: entry.path,
|
|
563
|
+
outcome: "bot-check-failed",
|
|
564
|
+
error: err instanceof Error ? err.name : "unknown",
|
|
565
|
+
});
|
|
566
|
+
return refusal(
|
|
567
|
+
503,
|
|
568
|
+
"bot_check_unavailable",
|
|
569
|
+
"The booking check is unavailable",
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
if (isBot) {
|
|
573
|
+
console.warn("[relay] bot refused", {
|
|
574
|
+
path: entry.path,
|
|
575
|
+
outcome: "bot-detected",
|
|
576
|
+
});
|
|
577
|
+
return refusal(403, "bot_detected", "Request refused");
|
|
578
|
+
}
|
|
579
|
+
} else {
|
|
580
|
+
warnBotCheckInactiveOnce();
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
469
584
|
try {
|
|
470
585
|
const result = await relayToApi(
|
|
471
586
|
entry.method === "GET" ? `${entry.path}${url.search}` : entry.path,
|