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,127 @@
1
+ /**
2
+ * GET /openapi.json
3
+ *
4
+ * The OpenAPI document `/.well-known/ai-plugin.json` advertises as its
5
+ * `api.url` (#7396). apps/api renders it at `/v1/openapi.json`; this route
6
+ * serves that same document on the web host, so the URL the manifest names
7
+ * answers here instead of 404ing. It is read over HTTP — apps/web never
8
+ * imports apps/api.
9
+ *
10
+ * A `servers[]` URL that is relative (apps/api emits `/` when
11
+ * AILK_PUBLIC_API_URL is unset) is resolved against the API origin before the
12
+ * document is served. Left relative, it would resolve against THIS host,
13
+ * which serves none of the API's paths.
14
+ *
15
+ * When the API cannot be reached, or answers anything but an OpenAPI
16
+ * document with a 200, this route answers 502 with a JSON error that names no
17
+ * upstream origin, and is never cached.
18
+ */
19
+
20
+ // Read per request: the document is the running API's, and `next build` has
21
+ // no API to read it from.
22
+ export const dynamic = "force-dynamic";
23
+
24
+ const UPSTREAM_PATH = "/v1/openapi.json";
25
+ const UPSTREAM_TIMEOUT_MS = 10_000;
26
+
27
+ const LOCAL_API_ORIGIN = "http://localhost:3001";
28
+
29
+ /**
30
+ * The apps/api origin this server reads from — the server-side resolution
31
+ * order of `getApiBase()` in `@working-theory/api-client`, which the package
32
+ * root does not export. The deprecated `NEXT_PUBLIC_API_URL` is not read.
33
+ */
34
+ function apiBase(): string {
35
+ return stripSlash(
36
+ process.env["AILK_API_URL"] ||
37
+ process.env["NEXT_PUBLIC_AILK_API_URL"] ||
38
+ LOCAL_API_ORIGIN,
39
+ );
40
+ }
41
+
42
+ /**
43
+ * The apps/api origin an agent reading the document calls — the
44
+ * browser-facing name first, since AILK_API_URL may be a private address.
45
+ */
46
+ function publicApiBase(): string {
47
+ return stripSlash(
48
+ process.env["NEXT_PUBLIC_AILK_API_URL"] ||
49
+ process.env["AILK_API_URL"] ||
50
+ LOCAL_API_ORIGIN,
51
+ );
52
+ }
53
+
54
+ function stripSlash(url: string): string {
55
+ return url.replace(/\/+$/, "");
56
+ }
57
+
58
+ interface OpenApiDocument {
59
+ openapi: string;
60
+ servers?: Array<{ url?: unknown; description?: unknown }>;
61
+ }
62
+
63
+ function isOpenApiDocument(value: unknown): value is OpenApiDocument {
64
+ return (
65
+ typeof value === "object" &&
66
+ value !== null &&
67
+ typeof (value as { openapi?: unknown }).openapi === "string"
68
+ );
69
+ }
70
+
71
+ /** Resolve every relative `servers[].url` against the public API origin. */
72
+ function withAbsoluteServers(spec: OpenApiDocument): OpenApiDocument {
73
+ if (!Array.isArray(spec.servers)) return spec;
74
+ const origin = publicApiBase();
75
+ return {
76
+ ...spec,
77
+ servers: spec.servers.map((server) =>
78
+ typeof server.url === "string" && !/^[a-z][a-z\d+.-]*:/i.test(server.url)
79
+ ? {
80
+ ...server,
81
+ url: stripSlash(new URL(server.url, `${origin}/`).href),
82
+ description: "This deployment's API",
83
+ }
84
+ : server,
85
+ ),
86
+ };
87
+ }
88
+
89
+ function badGateway(message: string): Response {
90
+ return Response.json(
91
+ { error: { code: "upstream_unavailable", message } },
92
+ { status: 502, headers: { "cache-control": "no-store" } },
93
+ );
94
+ }
95
+
96
+ export async function GET(): Promise<Response> {
97
+ let upstream: Response;
98
+ try {
99
+ upstream = await fetch(`${apiBase()}${UPSTREAM_PATH}`, {
100
+ cache: "no-store",
101
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
102
+ });
103
+ } catch {
104
+ return badGateway("The API did not answer.");
105
+ }
106
+
107
+ if (upstream.status !== 200) {
108
+ await upstream.body?.cancel();
109
+ return badGateway(`The API answered ${upstream.status}.`);
110
+ }
111
+
112
+ let spec: unknown;
113
+ try {
114
+ spec = await upstream.json();
115
+ } catch {
116
+ return badGateway("The API answered a body that is not JSON.");
117
+ }
118
+
119
+ if (!isOpenApiDocument(spec)) {
120
+ return badGateway("The API answered JSON that is not an OpenAPI document.");
121
+ }
122
+
123
+ return Response.json(withAbsoluteServers(spec), {
124
+ status: 200,
125
+ headers: { "cache-control": "public, max-age=3600" },
126
+ });
127
+ }
@@ -29,9 +29,11 @@ import type { PrismaClient } from "@working-theory/database";
29
29
  import { betterAuth } from "better-auth";
30
30
  import { prismaAdapter } from "better-auth/adapters/prisma";
31
31
  import { nextCookies } from "better-auth/next-js";
32
+ import { magicLink } from "better-auth/plugins";
32
33
 
33
34
  import {
34
35
  authEmailTransport,
36
+ makeMagicLinkOptions,
35
37
  makeSendPasswordChangedNotice,
36
38
  makeSendResetPassword,
37
39
  makeSendVerificationEmail,
@@ -59,16 +61,76 @@ const RESET_TOKEN_TTL_SECONDS = 3600;
59
61
  */
60
62
  const VERIFY_TOKEN_TTL_SECONDS = 86_400;
61
63
 
62
- // Lazy-load prisma-for-auth: import is deferred until createAuth() is called
63
- // without an explicit `prisma` dep. This prevents the PrismaClient singleton
64
- // from initialising (and calling @working-theory/database) at module-load time, which
65
- // lets tests import `createAuth` without triggering a DB connection.
66
- function getDefaultPrisma(): PrismaClient {
67
- // eslint-disable-next-line @typescript-eslint/no-require-imports
68
- const { prismaForAuth } = require("./lib/prisma-for-auth") as {
69
- prismaForAuth: PrismaClient;
70
- };
71
- return prismaForAuth;
64
+ let prismaForAuthLoad: Promise<PrismaClient> | undefined;
65
+
66
+ /**
67
+ * The `prismaForAuth` singleton, loaded once through `import()`. A failed load
68
+ * is not cached, so the next call retries — as the old in-`createAuth()`
69
+ * require did.
70
+ */
71
+ function loadPrismaForAuth(): Promise<PrismaClient> {
72
+ prismaForAuthLoad ??= import("./lib/prisma-for-auth").then(
73
+ (mod) => mod.prismaForAuth,
74
+ (err: unknown) => {
75
+ prismaForAuthLoad = undefined;
76
+ throw err;
77
+ },
78
+ );
79
+ return prismaForAuthLoad;
80
+ }
81
+
82
+ /**
83
+ * The default client: every member resolves the singleton before it runs.
84
+ *
85
+ * NEVER a synchronous `require()` of `./lib/prisma-for-auth` (#7401).
86
+ * `next build --webpack` compiles that module as an ASYNC module — it imports
87
+ * ESM (`@prisma/adapter-pg`, the generated client) — and a synchronous
88
+ * `require()` of an async webpack module returns a pending namespace, not its
89
+ * exports. `prismaAdapter()` received `undefined`, and every social sign-in
90
+ * 500'd on `Cannot read properties of undefined (reading 'verification')`.
91
+ * `import()` is the ESM-correct load: webpack awaits the module.
92
+ *
93
+ * Deferred rather than imported statically so the module still loads WITHOUT
94
+ * the generated client — `createAuth({ prisma })` tests import this file under
95
+ * CommonJS Jest, which cannot parse that client. Nothing is lost by the
96
+ * deferral: Better Auth, `onPasswordReset` and the reset mailer only touch the
97
+ * client inside request-time async calls. Each member is a function, so the
98
+ * adapter's `typeof db.$transaction === "function"` check holds and
99
+ * `$transaction(cb)` runs on the real client, handing `cb` a real transaction
100
+ * client. (The array form of `$transaction` needs real PrismaPromises; nothing
101
+ * here uses it.)
102
+ */
103
+ function deferredPrismaForAuth(): PrismaClient {
104
+ return new Proxy({} as PrismaClient, {
105
+ get(_, member) {
106
+ // Not a thenable, and no symbol-keyed members (inspection, tags).
107
+ if (typeof member !== "string" || member === "then") return undefined;
108
+ return new Proxy(() => undefined, {
109
+ // `$transaction(cb)`, `$connect()` and the other client methods.
110
+ apply: (_target, _this, args: unknown[]) =>
111
+ loadPrismaForAuth().then((client) =>
112
+ Reflect.apply(Reflect.get(client, member), client, args),
113
+ ),
114
+ // Model delegates: `verification.create(args)`, `user.findFirst(args)`.
115
+ // A delegate is not a thenable either.
116
+ get: (_target, method) =>
117
+ typeof method === "string" && method !== "then"
118
+ ? (...args: unknown[]) =>
119
+ loadPrismaForAuth().then((client) => {
120
+ const delegate: unknown = Reflect.get(client, member);
121
+ // Every member passes the adapter's `if (!db[model])` check,
122
+ // so name a missing model here instead of a bare TypeError.
123
+ if (typeof delegate !== "object" || delegate === null) {
124
+ throw new Error(
125
+ `prismaForAuth has no model delegate "${member}" — regenerate the Prisma client`,
126
+ );
127
+ }
128
+ return Reflect.apply(Reflect.get(delegate, method), delegate, args);
129
+ })
130
+ : undefined,
131
+ });
132
+ },
133
+ });
72
134
  }
73
135
 
74
136
  /**
@@ -104,10 +166,9 @@ function buildSocialProviders(): SocialProviders {
104
166
  };
105
167
  }
106
168
 
107
- // "email" is reported by getActiveProviderIds when AUTH_EMAIL_SERVER +
108
- // AUTH_EMAIL_FROM are set, but Better Auth core does not ship a built-in
109
- // magic-link provider — that wiring is deferred to a follow-up
110
- // (see spec §7 "Out of Scope").
169
+ // "email" (the password method) and "magic-link" (the emailed sign-in
170
+ // link, #7435) are not social providers: `createAuth()` wires them as
171
+ // `emailAndPassword` and the `magicLink` plugin respectively.
111
172
 
112
173
  return providers;
113
174
  }
@@ -215,7 +276,7 @@ export interface CreateAuthDeps {
215
276
  * existing callers (`import { auth }`) are unchanged.
216
277
  */
217
278
  export function createAuth(deps: CreateAuthDeps = {}) {
218
- const prisma = deps.prisma ?? getDefaultPrisma();
279
+ const prisma = deps.prisma ?? deferredPrismaForAuth();
219
280
 
220
281
  /**
221
282
  * ONE predicate, read in ONE place (#5962 § D2).
@@ -240,6 +301,12 @@ export function createAuth(deps: CreateAuthDeps = {}) {
240
301
  */
241
302
  const resetConfigured = resetSendConfigured();
242
303
 
304
+ /**
305
+ * The emailed sign-in link (#7435 § D1). The SAME predicate `/sign-in` and
306
+ * the `(authed)` gate read, so the form renders iff the endpoint exists.
307
+ */
308
+ const magicLinkActive = getActiveProviderIds().includes("magic-link");
309
+
243
310
  const trustedProxies = resolveTrustedProxies();
244
311
 
245
312
  /** Plain assignment, no cast — see `VerificationStore`. */
@@ -457,6 +524,11 @@ export function createAuth(deps: CreateAuthDeps = {}) {
457
524
  "/request-password-reset": { window: 900, max: 5 },
458
525
  "/reset-password": { window: 900, max: 10 },
459
526
  "/send-verification-email": { window: 900, max: 5 },
527
+ // #7435 § D6. Without a resolvable IP the plugin's own
528
+ // 5 / 60 s rule applies — for the shared-bucket reason above.
529
+ ...(magicLinkActive
530
+ ? { "/sign-in/magic-link": { window: 900, max: 5 } }
531
+ : {}),
460
532
  },
461
533
  }
462
534
  : {}),
@@ -526,13 +598,25 @@ export function createAuth(deps: CreateAuthDeps = {}) {
526
598
  expiresIn: 60 * 60 * 24 * 7, // 7 days
527
599
  updateAge: 60 * 60 * 24, // refresh in DB once per day
528
600
  },
529
- plugins: [nextCookies()],
601
+ /**
602
+ * `magicLink` only when its predicate holds (#7435 § D2): absent, its
603
+ * `/sign-in/magic-link` and `/magic-link/verify` endpoints do not exist.
604
+ * Sign-in only — the callback mails existing users and nobody else, and
605
+ * `disableSignUp` refuses to create one even for a live token
606
+ * (lib/auth-email.ts).
607
+ */
608
+ plugins: magicLinkActive
609
+ ? [
610
+ nextCookies(),
611
+ magicLink(makeMagicLinkOptions({ send: authEmailTransport })),
612
+ ]
613
+ : [nextCookies()],
530
614
  });
531
615
  }
532
616
 
533
617
  // Lazy singleton: resolved on first access, not at module-load time.
534
- // Defers the `require("./lib/prisma-for-auth")` call until auth is actually
535
- // used, so tests can `import { createAuth }` without triggering a DB connection.
618
+ // Defers `createAuth()` — and `resolveSecret()`'s production check — until
619
+ // auth is actually used.
536
620
  let _auth: ReturnType<typeof createAuth> | undefined;
537
621
 
538
622
  export const auth: ReturnType<typeof createAuth> = new Proxy(
@@ -1,11 +1,12 @@
1
1
  "use client"; // Better Auth's client signOut + router navigation need the browser
2
2
 
3
3
  /**
4
- * SignOutTrigger — the one client child of the server-rendered Nav (#4984).
4
+ * SignOutTrigger — wraps the signed-in Nav's "Sign out" trigger (#4984).
5
5
  *
6
6
  * The locale layout (RSC) reads the session and authors the "Sign out" nav
7
7
  * action as `kind: "flow"`; Nav hands the rendered trigger `<Button>` back
8
- * through `renderFlowTrigger`, and this component clones it with the real
8
+ * through `renderFlowTrigger` — composed in `SignedInNav`, on the client side
9
+ * of the RSC boundary (#7402) — and this component clones it with the real
9
10
  * sign-out handler. Same `createAuthClient()` idiom as `SignInGate`.
10
11
  */
11
12
 
@@ -0,0 +1,58 @@
1
+ "use client"; // owns renderFlowTrigger + navItems — functions that cannot cross the RSC boundary (#7402)
2
+
3
+ /**
4
+ * SignedInNav — the site Nav for a signed-in visitor (#7402).
5
+ *
6
+ * Signed in, the locale layout (RSC) authors two `kind: "flow"` Nav actions —
7
+ * the visitor's name and "Sign out" — and Nav hands their rendered triggers
8
+ * back through `renderFlowTrigger`. That composer is a function, and Nav
9
+ * forwards it into NavMobileMenu ("use client"), so a Server Component that
10
+ * passes it crashes every signed-in page with "Functions cannot be passed
11
+ * directly to Client Components". Composing Nav HERE keeps the function on the
12
+ * client side of the boundary.
13
+ *
14
+ * `navItems` is owned here for the same reason: a generated nav inventory
15
+ * carries Lucide icon components on its flyout entries, and a component is a
16
+ * function too. The layout passes only serializable props (labels, actions,
17
+ * the brand elements); everything callable is imported or defined below.
18
+ *
19
+ * Signed out, the layout keeps rendering Nav as a Server Component — it has
20
+ * no flow action, so nothing callable needs to cross.
21
+ */
22
+
23
+ import { Nav, type NavProps } from "@working-theory/ui/chrome";
24
+ import type { ReactElement } from "react";
25
+
26
+ import { navItems } from "../lib/nav-config";
27
+ import { siteChrome } from "../lib/site-chrome";
28
+
29
+ import { SignOutTrigger } from "./sign-out-trigger";
30
+
31
+ /** Nav's props minus every function-typed one — this type IS the boundary. */
32
+ export type SignedInNavProps = Omit<
33
+ NavProps,
34
+ "navItems" | "renderFlowTrigger" | "translate" | "onSearchOpen"
35
+ >;
36
+
37
+ export function SignedInNav(props: SignedInNavProps): ReactElement {
38
+ return (
39
+ <Nav
40
+ {...props}
41
+ navItems={siteChrome.nav === "mark-only" ? [] : navItems}
42
+ // Both signed-in actions are flows with no lightbox: Sign out becomes a
43
+ // SignOutTrigger, the account action a non-navigating name label.
44
+ renderFlowTrigger={(action, trigger) =>
45
+ action.labelKey === "signOut" ? (
46
+ <SignOutTrigger>{trigger}</SignOutTrigger>
47
+ ) : (
48
+ <span
49
+ className="text-body-sm text-(--color-text-muted)"
50
+ data-testid="nav-account-name"
51
+ >
52
+ {props.labels?.items.account}
53
+ </span>
54
+ )
55
+ }
56
+ />
57
+ );
58
+ }
@@ -60,6 +60,11 @@ module.exports = {
60
60
  "<rootDir>/node_modules/@working-theory/sections-registry/dist/index.js",
61
61
  "^@working-theory/templates/shells$":
62
62
  "<rootDir>/node_modules/@working-theory/templates/dist/shells/index.js",
63
+ // The account-area subpath (#7429, spec 7423 D7) — the contribution
64
+ // contract `lib/account-config.ts` types against, and the Inbox pieces
65
+ // the /account routes render.
66
+ "^@working-theory/templates/account$":
67
+ "<rootDir>/node_modules/@working-theory/templates/dist/account/index.js",
63
68
  // Pure-data routes-map subpath (#5572 U2) — mapped directly to source so
64
69
  // a consumer of the lightweight `./routes` export (routes.ts,
65
70
  // next.config.mjs, this suite) never pulls in the templates package's
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Tests for lib/account-config.ts — the base account area's contributions
3
+ * (#7429, spec 7423 U3 · AC-6; amended by #7448, U5 · D6a/D8a/D9).
4
+ *
5
+ * Covers:
6
+ * 1. The three contribution arrays are exported; the base area ships no
7
+ * Settings section (modules add those).
8
+ * 2. The base Inbox kinds are exactly `result` and `purchase` — rows of
9
+ * the Recents and All tabs; none fills Scheduled (the old Upcoming).
10
+ * 3. `email-unverified` and `payment-due` are For-you sources — needs-you
11
+ * items with an Open action to User settings, never Inbox rows.
12
+ * 4. Each contribution maps its existing read: the session for
13
+ * `email-unverified`, `waitlistSignups.me` per configured key for
14
+ * `result`, `flowCheckouts.me` for `purchase`, and `subscription.me`'s
15
+ * own `actionRequired` for `payment-due`.
16
+ * 5. A 404 — or a 401/403 while the email is unverified — is "nothing for
17
+ * this caller" (`[]`); any other failed read rejects, a 401/403 for a
18
+ * verified email included, so the Inbox can show an error row (D12).
19
+ */
20
+
21
+ const mockSignupMe = jest.fn();
22
+ const mockCheckoutsMe = jest.fn();
23
+ const mockSubscriptionMe = jest.fn();
24
+ jest.mock("@working-theory/api-client", () => ({
25
+ __esModule: true,
26
+ waitlistSignups: { me: (...args: unknown[]) => mockSignupMe(...args) },
27
+ flowCheckouts: { me: (...args: unknown[]) => mockCheckoutsMe(...args) },
28
+ subscription: { me: (...args: unknown[]) => mockSubscriptionMe(...args) },
29
+ }));
30
+
31
+ import type {
32
+ AccountContext,
33
+ AccountForYouSource,
34
+ AccountInboxKind,
35
+ } from "@working-theory/templates/account";
36
+
37
+ import {
38
+ accountExperimentKeys,
39
+ accountForYouSources,
40
+ accountInboxKinds,
41
+ accountSettingsSections,
42
+ } from "../account-config";
43
+
44
+ const INIT = { headers: { cookie: "session=abc" } };
45
+
46
+ function ctx(overrides: Partial<AccountContext["user"]> = {}): AccountContext {
47
+ return {
48
+ locale: "en",
49
+ user: {
50
+ name: "Ada",
51
+ email: "ada@example.com",
52
+ emailVerified: true,
53
+ ...overrides,
54
+ },
55
+ t: (key) => `t:${key}`,
56
+ init: INIT,
57
+ };
58
+ }
59
+
60
+ function kind(id: string): AccountInboxKind {
61
+ const found = accountInboxKinds.find((k) => k.kind === id);
62
+ if (!found) throw new Error(`no base kind ${id}`);
63
+ return found;
64
+ }
65
+
66
+ function source(id: string): AccountForYouSource {
67
+ const found = accountForYouSources.find((s) => s.id === id);
68
+ if (!found) throw new Error(`no base For-you source ${id}`);
69
+ return found;
70
+ }
71
+
72
+ const SIGNUP = {
73
+ experimentKey: "diag",
74
+ waitlistKey: "early",
75
+ completedAt: "2026-09-01T10:00:00.000Z",
76
+ tier: "gold",
77
+ score: 42,
78
+ aeoScore: null,
79
+ answers: { q3: null },
80
+ };
81
+
82
+ const CHECKOUT = {
83
+ id: "flchk_1",
84
+ flowSlug: "buy",
85
+ experimentKey: "diag",
86
+ offerKey: "pro",
87
+ status: "completed",
88
+ amountMinor: 4900,
89
+ currency: "USD",
90
+ period: "month",
91
+ completedAt: "2026-09-02T10:00:00.000Z",
92
+ createdAt: "2026-09-02T09:00:00.000Z",
93
+ };
94
+
95
+ const ok = <T>(body: T) => ({ ok: true as const, body });
96
+ const fail = (status: number) => ({ ok: false as const, status, body: null });
97
+
98
+ afterEach(() => {
99
+ jest.clearAllMocks();
100
+ accountExperimentKeys.length = 0;
101
+ });
102
+
103
+ describe("account-config exports (AC-6)", () => {
104
+ it("exports the three contribution arrays", () => {
105
+ expect(Array.isArray(accountInboxKinds)).toBe(true);
106
+ expect(accountSettingsSections).toEqual([]);
107
+ expect(Array.isArray(accountForYouSources)).toBe(true);
108
+ });
109
+
110
+ it("ships exactly the two base Inbox kinds, both Recents rows (D6a, D9)", () => {
111
+ expect(accountInboxKinds.map((k) => [k.kind, k.section])).toEqual([
112
+ ["result", "updates"],
113
+ ["purchase", "updates"],
114
+ ]);
115
+ // Nothing in the base area fills Scheduled (the old Upcoming), and no
116
+ // Inbox row asks for a decision — that is the For-you panel's job.
117
+ expect(accountInboxKinds.some((k) => k.section === "upcoming")).toBe(false);
118
+ expect(accountInboxKinds.some((k) => k.section === "needs-you")).toBe(false);
119
+ });
120
+
121
+ it("ships email-unverified and payment-due as For-you sources, not Inbox kinds (D9 as amended)", () => {
122
+ expect(accountForYouSources.map((s) => s.id)).toEqual([
123
+ "email-unverified",
124
+ "payment-due",
125
+ ]);
126
+ const kinds = accountInboxKinds.map((k) => k.kind);
127
+ expect(kinds).not.toContain("email-unverified");
128
+ expect(kinds).not.toContain("payment-due");
129
+ });
130
+
131
+ it("gives every kind a unique id — the `[kind]` route segment", () => {
132
+ const ids = accountInboxKinds.map((k) => k.kind);
133
+ expect(new Set(ids).size).toBe(ids.length);
134
+ });
135
+
136
+ it("names every kind by a message key, never a literal", () => {
137
+ for (const k of accountInboxKinds)
138
+ expect(k.label).toMatch(/^[a-z]+(\.[A-Za-z]+)+$/);
139
+ });
140
+ });
141
+
142
+ describe("email-unverified — a For-you item from the session", () => {
143
+ it("is one needs-you item, opening User settings › General", async () => {
144
+ const items = await source("email-unverified").load(
145
+ ctx({ emailVerified: false }),
146
+ );
147
+ expect(items).toHaveLength(1);
148
+ expect(items[0]).toMatchObject({
149
+ id: "email-unverified",
150
+ type: { id: "email", label: "t:profile.email", tone: "needs-you" },
151
+ title: "t:profile.unverified",
152
+ action: { kind: "open", href: "/account/settings#general" },
153
+ dismissible: true,
154
+ });
155
+ });
156
+
157
+ it("is empty when the email is verified, and reads no API", async () => {
158
+ await expect(source("email-unverified").load(ctx())).resolves.toEqual([]);
159
+ expect(mockSignupMe).not.toHaveBeenCalled();
160
+ expect(mockCheckoutsMe).not.toHaveBeenCalled();
161
+ expect(mockSubscriptionMe).not.toHaveBeenCalled();
162
+ });
163
+ });
164
+
165
+ describe("result — waitlistSignups.me per accountExperimentKeys", () => {
166
+ it("reads each configured key with the forwarded init, skipping a 404", async () => {
167
+ accountExperimentKeys.push("diag", "other");
168
+ mockSignupMe.mockImplementation(async (key: string) =>
169
+ key === "diag" ? ok(SIGNUP) : fail(404),
170
+ );
171
+ const items = await kind("result").load(ctx());
172
+ expect(mockSignupMe).toHaveBeenCalledWith("diag", INIT);
173
+ expect(mockSignupMe).toHaveBeenCalledWith("other", INIT);
174
+ expect(items).toEqual([
175
+ { id: "diag", title: "diag", at: SIGNUP.completedAt, data: SIGNUP },
176
+ ]);
177
+ });
178
+
179
+ it("is empty with no configured key", async () => {
180
+ await expect(kind("result").load(ctx())).resolves.toEqual([]);
181
+ expect(mockSignupMe).not.toHaveBeenCalled();
182
+ });
183
+
184
+ it("rejects on a failed read", async () => {
185
+ accountExperimentKeys.push("diag");
186
+ mockSignupMe.mockResolvedValue(fail(500));
187
+ await expect(kind("result").load(ctx())).rejects.toThrow();
188
+ });
189
+ });
190
+
191
+ describe("purchase — flowCheckouts.me", () => {
192
+ it("is one item per checkout, at its completion time", async () => {
193
+ mockCheckoutsMe.mockResolvedValue(ok([CHECKOUT]));
194
+ const items = await kind("purchase").load(ctx());
195
+ expect(mockCheckoutsMe).toHaveBeenCalledWith(INIT);
196
+ expect(items).toEqual([
197
+ { id: "flchk_1", title: "pro", at: CHECKOUT.completedAt, data: CHECKOUT },
198
+ ]);
199
+ });
200
+
201
+ it("is empty on a 404", async () => {
202
+ mockCheckoutsMe.mockResolvedValue(fail(404));
203
+ await expect(kind("purchase").load(ctx())).resolves.toEqual([]);
204
+ });
205
+
206
+ it.each([401, 403])(
207
+ "is empty on a %i while the email is unverified",
208
+ async (status) => {
209
+ mockCheckoutsMe.mockResolvedValue(fail(status));
210
+ await expect(
211
+ kind("purchase").load(ctx({ emailVerified: false })),
212
+ ).resolves.toEqual([]);
213
+ },
214
+ );
215
+
216
+ it.each([401, 403])(
217
+ "rejects on a %i for a verified email",
218
+ async (status) => {
219
+ mockCheckoutsMe.mockResolvedValue(fail(status));
220
+ await expect(kind("purchase").load(ctx())).rejects.toMatchObject({
221
+ status,
222
+ });
223
+ },
224
+ );
225
+
226
+ it("rejects on a failed read", async () => {
227
+ mockCheckoutsMe.mockResolvedValue(fail(503));
228
+ await expect(kind("purchase").load(ctx())).rejects.toThrow();
229
+ });
230
+
231
+ it("details the checkout's fields", () => {
232
+ const item = {
233
+ id: "flchk_1",
234
+ title: "pro",
235
+ at: CHECKOUT.completedAt,
236
+ data: CHECKOUT,
237
+ };
238
+ const detail = kind("purchase").renderDetail(item, ctx());
239
+ expect(detail.status).toBe("completed");
240
+ expect(detail.fields?.items.map((i) => i.label)).toEqual([
241
+ "t:purchases.offer",
242
+ "t:purchases.amount",
243
+ "t:purchases.period",
244
+ "t:purchases.date",
245
+ ]);
246
+ });
247
+ });
248
+
249
+ describe("payment-due — a For-you item from subscription.me's actionRequired", () => {
250
+ const SUB = {
251
+ actionRequired: true,
252
+ status: "past_due",
253
+ accessRevokesAt: null,
254
+ portalAvailable: true,
255
+ };
256
+
257
+ it("is one needs-you item, opening User settings › Billing, when the API says action is required", async () => {
258
+ mockSubscriptionMe.mockResolvedValue(ok(SUB));
259
+ const items = await source("payment-due").load(ctx());
260
+ expect(mockSubscriptionMe).toHaveBeenCalledWith(INIT);
261
+ expect(items).toHaveLength(1);
262
+ expect(items[0]).toMatchObject({
263
+ id: "payment-due",
264
+ type: { id: "payment", label: "t:subscription.action", tone: "needs-you" },
265
+ title: "t:subscription.headline",
266
+ action: { kind: "open", href: "/account/settings#billing" },
267
+ dismissible: true,
268
+ });
269
+ });
270
+
271
+ it("is empty when it is not — the status is never re-derived", async () => {
272
+ mockSubscriptionMe.mockResolvedValue(ok({ ...SUB, actionRequired: false }));
273
+ await expect(source("payment-due").load(ctx())).resolves.toEqual([]);
274
+ });
275
+
276
+ it("is empty on a 404 (the commerce-disabled build)", async () => {
277
+ mockSubscriptionMe.mockResolvedValue(fail(404));
278
+ await expect(source("payment-due").load(ctx())).resolves.toEqual([]);
279
+ });
280
+
281
+ it("rejects on a failed read", async () => {
282
+ mockSubscriptionMe.mockResolvedValue(fail(500));
283
+ await expect(source("payment-due").load(ctx())).rejects.toThrow();
284
+ });
285
+ });