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.
Files changed (75) hide show
  1. package/README.md +14 -5
  2. package/dist/lib/fetch-template-snapshot.d.ts +2 -1
  3. package/dist/lib/fetch-template-snapshot.js +6 -5
  4. package/dist/lib/module-license-gate.js +2 -3
  5. package/dist/lib/module-tier.d.ts +2 -8
  6. package/dist/lib/module-tier.js +13 -12
  7. package/dist/module-architecture.js +14 -2
  8. package/dist/starter-grid.d.ts +6 -6
  9. package/dist/starter-grid.js +12 -5
  10. package/dist/surfaces.js +36 -2
  11. package/dist/template-record.d.ts +11 -3
  12. package/dist/template-record.js +20 -7
  13. package/dist/topology.js +23 -4
  14. package/dist/update.d.ts +1 -1
  15. package/dist/update.js +240 -20
  16. package/package.json +3 -3
  17. package/release-scope.json +1 -1
  18. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +8 -2
  19. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +2 -2
  20. package/site-templates/marketing-site/ailk-template.json +7 -1
  21. package/site-templates/marketing-site/provenance.json +7 -1
  22. package/site-templates/saas-product/ailk-template.json +2 -1
  23. package/site-templates/saas-product/provenance.json +1 -0
  24. package/templates/.env.example +22 -0
  25. package/templates/CLAUDE.md +20 -20
  26. package/templates/README.md +19 -19
  27. package/templates/apps/api/.env.example +28 -0
  28. package/templates/apps/api/CLAUDE.md +35 -9
  29. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
  30. package/templates/apps/api/vercel.json +7 -1
  31. package/templates/apps/mcp/CLAUDE.md +27 -10
  32. package/templates/apps/web/.env.example +13 -0
  33. package/templates/apps/web/CLAUDE.md +34 -6
  34. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
  35. package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
  36. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
  37. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +168 -8
  38. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
  39. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +22 -39
  40. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
  41. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
  42. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +301 -0
  43. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
  44. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -18
  45. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
  46. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
  47. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
  48. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
  49. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
  50. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
  51. package/templates/apps/web/app/[locale]/layout.tsx +48 -13
  52. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
  53. package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
  54. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +118 -0
  55. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +14 -0
  56. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
  57. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
  58. package/templates/apps/web/auth.ts +8 -4
  59. package/templates/apps/web/i18n/request.ts +49 -1
  60. package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
  61. package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
  62. package/templates/apps/web/lib/__tests__/relay.test.ts +289 -1
  63. package/templates/apps/web/lib/auth-email.ts +102 -20
  64. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +92 -23
  65. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +97 -70
  66. package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
  67. package/templates/apps/web/lib/relay.ts +116 -1
  68. package/templates/apps/web/lib/sign-up-agreements.ts +302 -28
  69. package/templates/apps/web/lib/site-contributions.ts +18 -0
  70. package/templates/apps/web/messages/en.json +271 -20
  71. package/templates/apps/web/messages/site/en.json +1 -0
  72. package/templates/apps/web/middleware.ts +8 -1
  73. package/templates/apps/web/next.config.mjs +9 -1
  74. package/templates/apps/web/package.json +2 -1
  75. 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 site's inner-page header (the back arrow at the start
14
- * edge, the mark centred — the shape `Nav bar="bare"` draws, composed here
15
- * from the same `NavBackLink` and `siteLogo()`), a heading at the label size
16
- * (`heading-2`), and an optional quiet prospect line a site turns on by
17
- * adding `account.gate.prospectPrompt` + `prospectLink` + `prospectHref`
18
- * to its messages — absent, the shipped default, renders nothing.
19
- * The document title is the account page's job (`generateMetadata` there
20
- * reads the session and names this state "Sign in · <site>").
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), it renders the account area's existing "sign-in
24
- * isn't available yet" state, whose one action leads back to the site.
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, Heading, Link, Text } from "@working-theory/ui/primitives";
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 configured = hubSessionConfig() !== null;
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
- const prospect =
60
- has("prospectPrompt") && has("prospectLink") && has("prospectHref")
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
- : null;
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, the mark centred (#8790 AC-1). */}
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="grid h-16 grid-cols-[1fr_auto_1fr] items-center px-5 md:px-8"
94
+ className="flex h-16 items-center px-5 md:px-8"
76
95
  data-testid="account-sign-in-gate-header"
77
96
  >
78
- <div className="justify-self-start">
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 gap-4 py-section text-center">
92
- <Heading level={1} visual={2}>
93
- {configured ? t("hubHeadline") : t("emptyHeadline")}
94
- </Heading>
95
- <Text variant="body-sm" tone="muted" className="max-w-xs">
96
- {configured ? t("hubDescription") : t("emptyDescription")}
97
- </Text>
98
- {configured ? (
99
- // The primary action: 44px on a phone, 36px from md up.
100
- <Button asChild className="max-md:min-h-11">
101
- <Link
102
- href={`${HUB_SIGN_IN_ROUTE}?next=${encodeURIComponent(next)}`}
103
- variant="bare"
104
- data-testid="account-hub-sign-in"
105
- >
106
- {t("hubSignIn")}
107
- </Link>
108
- </Button>
109
- ) : (
110
- <Button asChild variant="secondary" className="max-md:min-h-11">
111
- <Link
112
- href={home}
113
- variant="bare"
114
- data-testid="account-sign-in-gate-home"
115
- >
116
- {t("homeLink")}
117
- </Link>
118
- </Button>
119
- )}
120
- {configured && prospect ? (
121
- // A wrapping div carries the hook: `Text` forwards no data-* attributes.
122
- <div data-testid="account-sign-in-gate-prospect">
123
- <Text as="p" variant="body-sm" tone="muted">
124
- {prospect.prompt}{" "}
125
- <Link href={prospect.href} variant="inline">
126
- {prospect.linkLabel}
127
- </Link>
128
- .
129
- </Text>
130
- </div>
131
- ) : null}
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. It imports
18
- * nothing from here; it reaches the client through the adapter.
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
- route("POST", "/v1/schedule/book"),
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,