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.
- package/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/content-payloads.js +2 -2
- package/dist/module-architecture.js +11 -0
- package/dist/surfaces.d.ts +4 -5
- package/dist/surfaces.js +50 -8
- package/package.json +3 -3
- package/templates/.env.example +42 -3
- package/templates/apps/api/.env.example +5 -0
- package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
- package/templates/apps/api/src/__tests__/server.test.ts +5 -1
- package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
- package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
- package/templates/apps/api/src/config/index.ts +136 -0
- package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
- package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
- package/templates/apps/api/src/middleware/auth.ts +19 -21
- package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
- package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
- package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
- package/templates/apps/api/src/server.ts +49 -16
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
- package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
- package/templates/apps/api/src/vercel-handler.ts +58 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
- package/templates/apps/web/.env.example +4 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
- package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
- package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
- package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
- package/templates/apps/web/app/[locale]/layout.tsx +98 -84
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
- package/templates/apps/web/app/openapi.json/route.ts +127 -0
- package/templates/apps/web/auth.ts +102 -18
- package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
- package/templates/apps/web/components/signed-in-nav.tsx +58 -0
- package/templates/apps/web/jest.config.cjs +5 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
- package/templates/apps/web/lib/account-config.ts +219 -11
- package/templates/apps/web/lib/auth-email.ts +104 -2
- package/templates/apps/web/lib/auth-env.ts +37 -5
- package/templates/apps/web/lib/auth-routes.ts +33 -4
- package/templates/apps/web/messages/en.json +147 -21
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
- 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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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"
|
|
108
|
-
//
|
|
109
|
-
//
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
535
|
-
//
|
|
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
|
|
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
|
|
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
|
+
});
|