create-ailk 0.11.1 → 0.11.3

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 (105) hide show
  1. package/component-catalog.json +12 -1
  2. package/component-catalog.md +6 -5
  3. package/dist/content-payloads.js +2 -2
  4. package/dist/module-architecture.js +11 -0
  5. package/dist/surfaces.d.ts +4 -5
  6. package/dist/surfaces.js +50 -8
  7. package/package.json +3 -3
  8. package/templates/.env.example +42 -3
  9. package/templates/apps/api/.env.example +5 -0
  10. package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
  11. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
  12. package/templates/apps/api/src/__tests__/server.test.ts +5 -1
  13. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
  14. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
  15. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
  16. package/templates/apps/api/src/config/index.ts +136 -0
  17. package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
  18. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
  19. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
  20. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
  21. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
  22. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
  23. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
  24. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
  25. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
  26. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
  27. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
  28. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
  29. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
  30. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
  31. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
  32. package/templates/apps/api/src/middleware/auth.ts +19 -21
  33. package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
  34. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
  35. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
  36. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
  37. package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
  38. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
  39. package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
  40. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
  41. package/templates/apps/api/src/server.ts +49 -16
  42. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
  43. package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
  44. package/templates/apps/api/src/vercel-handler.ts +58 -12
  45. package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
  46. package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
  47. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
  48. package/templates/apps/web/.env.example +4 -0
  49. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
  50. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
  51. package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
  52. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
  53. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
  54. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
  55. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
  56. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
  57. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
  58. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
  59. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
  60. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
  61. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
  62. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
  63. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
  65. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
  67. package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
  69. package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
  70. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
  72. package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
  78. package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
  79. package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
  80. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
  81. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
  82. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
  83. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
  84. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
  85. package/templates/apps/web/app/[locale]/layout.tsx +98 -84
  86. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
  87. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
  88. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
  89. package/templates/apps/web/app/openapi.json/route.ts +127 -0
  90. package/templates/apps/web/auth.ts +102 -18
  91. package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
  92. package/templates/apps/web/components/signed-in-nav.tsx +58 -0
  93. package/templates/apps/web/jest.config.cjs +5 -0
  94. package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
  95. package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
  96. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
  97. package/templates/apps/web/lib/account-config.ts +219 -11
  98. package/templates/apps/web/lib/auth-email.ts +104 -2
  99. package/templates/apps/web/lib/auth-env.ts +37 -5
  100. package/templates/apps/web/lib/auth-routes.ts +33 -4
  101. package/templates/apps/web/messages/en.json +147 -21
  102. package/templates/database/CHANGELOG.md +7 -0
  103. package/templates/database/package.json +1 -1
  104. package/versions.json +33 -33
  105. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Tests for the account layout — the base area's SignedInShell frame
3
+ * (#7430, spec 7423 U4 · D13; the U4 half of AC-17).
4
+ *
5
+ * Covers:
6
+ * 1. The rail: the site logo as the top tile (a link home), Inbox as its
7
+ * only row, a footer of the avatar and ? only.
8
+ * 2. The avatar opens the card menu (name, then Settings and Log out side
9
+ * by side); Log out signs out and routes home.
10
+ * 3. ? opens Contact support (→ /account/support) and, only when the site
11
+ * configures a help URL, Help center.
12
+ * 4. Below md the tab bar reads avatar · Inbox · Help.
13
+ * 5. The For-you items are the aside; none → no aside.
14
+ * 6. No site chrome (Nav, Footer, ThemeToggle) inside the frame.
15
+ */
16
+ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
17
+
18
+ jest.mock("next-intl/server", () => {
19
+ const msgs: unknown = require("../../../../../messages/en.json");
20
+ const dig = (obj: unknown, path: string): unknown =>
21
+ path.split(".").reduce<unknown>(
22
+ (acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
23
+ obj,
24
+ );
25
+ return {
26
+ __esModule: true,
27
+ getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
28
+ const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
29
+ const messages = dig(msgs, ns);
30
+ return (key: string, values?: Record<string, unknown>): string => {
31
+ const value = dig(messages, key);
32
+ if (typeof value !== "string") return `${ns}.${key}`;
33
+ return value.replace(/\{(\w+)\}/g, (m, name: string) =>
34
+ values && name in values ? String(values[name]) : m,
35
+ );
36
+ };
37
+ },
38
+ };
39
+ });
40
+
41
+ jest.mock("next/headers", () => ({
42
+ __esModule: true,
43
+ headers: jest.fn().mockResolvedValue(new Headers()),
44
+ cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
45
+ }));
46
+
47
+ const mockGetSession = jest.fn();
48
+ // eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; auth.ts's lazy Better-Auth singleton has no HTTP/DB boundary a unit test of the session fields can mock without standing up Prisma.
49
+ jest.mock("../../../../../auth", () => ({
50
+ __esModule: true,
51
+ auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
52
+ }));
53
+
54
+ const mockCheckoutsMe = jest.fn();
55
+ const mockSubscriptionMe = jest.fn();
56
+ jest.mock("@working-theory/api-client", () => ({
57
+ __esModule: true,
58
+ waitlistSignups: { me: jest.fn() },
59
+ flowCheckouts: { me: (...args: unknown[]) => mockCheckoutsMe(...args) },
60
+ subscription: { me: (...args: unknown[]) => mockSubscriptionMe(...args) },
61
+ }));
62
+
63
+ const mockSignOut = jest.fn();
64
+ jest.mock("better-auth/react", () => ({
65
+ __esModule: true,
66
+ createAuthClient: () => ({ signOut: (...args: unknown[]) => mockSignOut(...args) }),
67
+ }));
68
+
69
+ const mockPush = jest.fn();
70
+ const mockRefresh = jest.fn();
71
+ let mockPathname = "/account";
72
+ jest.mock("next/navigation", () => ({
73
+ __esModule: true,
74
+ useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
75
+ usePathname: () => mockPathname,
76
+ }));
77
+
78
+ import AccountLayout from "../layout";
79
+
80
+ const SESSION = {
81
+ user: {
82
+ id: "user_1",
83
+ name: "Ada Lovelace",
84
+ email: "ada@example.com",
85
+ emailVerified: true,
86
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
87
+ },
88
+ };
89
+
90
+ const NO_SUBSCRIPTION = {
91
+ ok: true,
92
+ body: { actionRequired: false, status: null, accessRevokesAt: null, portalAvailable: false },
93
+ };
94
+
95
+ // Pointer, not keyboard: Radix menus open on pointerdown. jsdom has no
96
+ // PointerEvent; a MouseEvent stands in (the packages/ui jest-setup idiom).
97
+ beforeAll(() => {
98
+ if (typeof globalThis.PointerEvent === "undefined") {
99
+ // eslint-disable-next-line no-restricted-syntax -- a MouseEvent stands in for jsdom's missing PointerEvent
100
+ globalThis.PointerEvent = MouseEvent as unknown as typeof PointerEvent;
101
+ }
102
+ });
103
+
104
+ function openByPointer(trigger: HTMLElement): void {
105
+ fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: "mouse" });
106
+ }
107
+
108
+ async function renderLayout(locale = "en"): Promise<void> {
109
+ const element = await AccountLayout({
110
+ children: <p data-testid="account-child">page</p>,
111
+ params: Promise.resolve({ locale }),
112
+ });
113
+ render(element);
114
+ }
115
+
116
+ const ORIGINAL_HELP_URL = process.env.AILK_HELP_CENTER_URL;
117
+
118
+ beforeEach(() => {
119
+ window.localStorage.clear();
120
+ mockPathname = "/account";
121
+ delete process.env.AILK_HELP_CENTER_URL;
122
+ mockGetSession.mockReset().mockResolvedValue(SESSION);
123
+ mockCheckoutsMe.mockReset().mockResolvedValue({ ok: true, body: [] });
124
+ mockSubscriptionMe.mockReset().mockResolvedValue(NO_SUBSCRIPTION);
125
+ mockSignOut.mockReset().mockResolvedValue({ data: {}, error: null });
126
+ mockPush.mockReset();
127
+ mockRefresh.mockReset();
128
+ });
129
+
130
+ afterAll(() => {
131
+ if (ORIGINAL_HELP_URL === undefined) delete process.env.AILK_HELP_CENTER_URL;
132
+ else process.env.AILK_HELP_CENTER_URL = ORIGINAL_HELP_URL;
133
+ });
134
+
135
+ describe("the account rail (D13, AC-17)", () => {
136
+ it("the top tile is the site logo — a link to the site home", async () => {
137
+ await renderLayout();
138
+ const rail = screen.getByTestId("sidebar-desktop");
139
+ const home = within(rail).getByTestId("account-site-logo");
140
+ expect(home).toHaveAttribute("href", "/");
141
+ expect(home).toHaveAccessibleName("Home");
142
+ expect(within(home).getByRole("img")).toBeInTheDocument();
143
+ });
144
+
145
+ it("a non-default locale's logo links to that locale's home", async () => {
146
+ await renderLayout("fr");
147
+ const rail = screen.getByTestId("sidebar-desktop");
148
+ expect(within(rail).getByTestId("account-site-logo")).toHaveAttribute("href", "/fr/");
149
+ });
150
+
151
+ it("Inbox is the rail's only row, selected on /account and on an item page", async () => {
152
+ await renderLayout();
153
+ const rail = screen.getByTestId("sidebar-desktop");
154
+ const links = within(rail)
155
+ .getAllByRole("link")
156
+ .filter((link) => link.getAttribute("data-testid") !== "account-site-logo");
157
+ expect(links).toHaveLength(1);
158
+ expect(links[0]).toHaveTextContent("Inbox");
159
+ expect(links[0]).toHaveAttribute("href", "/account");
160
+ expect(links[0]).toHaveAttribute("aria-current", "page");
161
+ });
162
+
163
+ it("an Inbox item page keeps Inbox selected", async () => {
164
+ mockPathname = "/account/inbox/purchase/fc_1";
165
+ await renderLayout();
166
+ const rail = screen.getByTestId("sidebar-desktop");
167
+ expect(within(rail).getByText("Inbox").closest("a")).toHaveAttribute("aria-current", "page");
168
+ });
169
+
170
+ it("the footer is the avatar and ? only — no name beside the avatar", async () => {
171
+ await renderLayout();
172
+ const footer = screen.getByTestId("signed-in-shell-rail-footer");
173
+ expect(within(footer).getAllByRole("button")).toHaveLength(2);
174
+ expect(screen.getByTestId("sidebar-nav-user-trigger")).not.toHaveTextContent("Ada Lovelace");
175
+ expect(within(footer).getByTestId("signed-in-shell-help-trigger")).toHaveAccessibleName("Help");
176
+ });
177
+
178
+ it("the page renders inside the frame, with no site chrome", async () => {
179
+ await renderLayout();
180
+ expect(screen.getByTestId("account-child")).toBeInTheDocument();
181
+ expect(document.querySelector('nav[aria-label="Primary"]')).not.toBeInTheDocument();
182
+ expect(document.querySelector(".ailk-footer")).not.toBeInTheDocument();
183
+ expect(screen.queryByTestId("theme-toggle")).not.toBeInTheDocument();
184
+ });
185
+ });
186
+
187
+ describe("the avatar menu (D13)", () => {
188
+ it("opens the card menu: the name, then Settings and Log out side by side", async () => {
189
+ await renderLayout();
190
+ openByPointer(screen.getByTestId("sidebar-nav-user-trigger"));
191
+ const menu = screen.getByTestId("account-menu-content");
192
+ expect(menu).toHaveAttribute("data-layout", "card");
193
+ expect(within(menu).getByTestId("account-menu-user")).toHaveTextContent("Ada Lovelace");
194
+ // The base area knows no plan, so the plan line is absent.
195
+ expect(within(menu).queryByTestId("account-menu-plan")).not.toBeInTheDocument();
196
+ const actions = within(menu).getByTestId("account-menu-actions");
197
+ expect(within(actions).getByText("Settings").closest("a")).toHaveAttribute(
198
+ "href",
199
+ "/account/settings",
200
+ );
201
+ expect(within(actions).getByTestId("account-menu-sign-out")).toHaveTextContent("Log out");
202
+ });
203
+
204
+ it("Log out signs out, then routes home and refreshes", async () => {
205
+ await renderLayout();
206
+ openByPointer(screen.getByTestId("sidebar-nav-user-trigger"));
207
+ fireEvent.click(screen.getByTestId("account-menu-sign-out"));
208
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/"));
209
+ expect(mockSignOut).toHaveBeenCalledTimes(1);
210
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
211
+ });
212
+
213
+ it("a member with no name is shown by their email", async () => {
214
+ mockGetSession.mockResolvedValue({ user: { ...SESSION.user, name: "" } });
215
+ await renderLayout();
216
+ openByPointer(screen.getByTestId("sidebar-nav-user-trigger"));
217
+ expect(screen.getByTestId("account-menu-user")).toHaveTextContent("ada@example.com");
218
+ });
219
+ });
220
+
221
+ describe("the help menu (D13)", () => {
222
+ it("? opens Contact support alone when the site configures no help URL", async () => {
223
+ await renderLayout();
224
+ openByPointer(screen.getByTestId("signed-in-shell-help-trigger"));
225
+ const menu = screen.getByTestId("signed-in-shell-help-menu");
226
+ expect(within(menu).getAllByRole("link")).toHaveLength(1);
227
+ expect(within(menu).getByText("Contact support").closest("a")).toHaveAttribute(
228
+ "href",
229
+ "/account/support",
230
+ );
231
+ });
232
+
233
+ it("with a help URL configured, Help center follows Contact support", async () => {
234
+ process.env.AILK_HELP_CENTER_URL = "https://help.example.com";
235
+ await renderLayout();
236
+ openByPointer(screen.getByTestId("signed-in-shell-help-trigger"));
237
+ const menu = screen.getByTestId("signed-in-shell-help-menu");
238
+ const links = within(menu).getAllByRole("link");
239
+ expect(links.map((link) => link.textContent)).toEqual(["Contact support", "Help center"]);
240
+ expect(links[1]).toHaveAttribute("href", "https://help.example.com");
241
+ });
242
+ });
243
+
244
+ describe("the phone frame (D13)", () => {
245
+ it("the tab bar reads avatar · Inbox · Help, and Help opens the same menu", async () => {
246
+ await renderLayout();
247
+ const bar = screen.getByTestId("bottom-tab-bar");
248
+ const cells = Array.from(bar.children);
249
+ expect(cells).toHaveLength(3);
250
+ expect(cells[0]).toBe(screen.getByTestId("bottom-tab-bar-leading"));
251
+ expect(cells[1]).toHaveTextContent("Inbox");
252
+ expect(cells[1]).toHaveAttribute("aria-current", "page");
253
+ expect(cells[2]).toBe(screen.getByTestId("bottom-tab-bar-trailing"));
254
+ openByPointer(screen.getByTestId("frame-bottom-bar-help"));
255
+ expect(
256
+ within(screen.getByTestId("signed-in-shell-help-menu")).getByText("Contact support"),
257
+ ).toBeInTheDocument();
258
+ });
259
+
260
+ it("the phone top bar carries the site logo, linking home", async () => {
261
+ await renderLayout();
262
+ const logos = screen.getAllByTestId("account-site-logo");
263
+ // One in the rail, one in the phone top bar.
264
+ expect(logos).toHaveLength(2);
265
+ for (const logo of logos) expect(logo).toHaveAttribute("href", "/");
266
+ });
267
+ });
268
+
269
+ describe("the For-you aside (D3, D8a)", () => {
270
+ it("no items → no aside", async () => {
271
+ await renderLayout();
272
+ expect(screen.queryByTestId("app-shell-aside")).not.toBeInTheDocument();
273
+ });
274
+
275
+ it("items → the For-you panel is the aside", async () => {
276
+ mockGetSession.mockResolvedValue({ user: { ...SESSION.user, emailVerified: false } });
277
+ await renderLayout();
278
+ const panel = screen.getByTestId("app-shell-aside");
279
+ expect(within(panel).getByText("Email not verified")).toBeInTheDocument();
280
+ });
281
+
282
+ it("a member with no name sees 'Add your name' in the aside", async () => {
283
+ mockGetSession.mockResolvedValue({ user: { ...SESSION.user, name: null } });
284
+ await renderLayout();
285
+ const panel = screen.getByTestId("app-shell-aside");
286
+ expect(within(panel).getByText("Add your name")).toBeInTheDocument();
287
+ });
288
+ });
289
+
290
+ describe("no session", () => {
291
+ it("renders the page alone — the (authed) layout owns the gate", async () => {
292
+ mockGetSession.mockResolvedValue(null);
293
+ await renderLayout();
294
+ expect(screen.getByTestId("account-child")).toBeInTheDocument();
295
+ expect(screen.queryByTestId("sidebar-desktop")).not.toBeInTheDocument();
296
+ });
297
+ });