create-ailk 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +4 -1
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +9 -0
- package/dist/lib/scaffold-render-sweep.js +24 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +119 -0
- package/dist/programmatic.js +3 -0
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.js +140 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +60 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/CLAUDE.md +25 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +81 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
- package/templates/apps/api/src/middleware/auth.ts +206 -46
- package/templates/apps/api/src/routes/leads/index.ts +4 -0
- package/templates/apps/api/src/server.ts +107 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
- package/templates/apps/api/src/services/lead-routing.ts +30 -2
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +9 -4
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +5 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/topology.generated.ts +24 -0
- package/templates/apps/web/messages/en.json +156 -35
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +90 -15
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* account/sign-in-method.ts (server code) — how a member signs in, for User settings ›
|
|
3
|
+
* General's Sign-in section (#8232, spec
|
|
4
|
+
* docs/specs/8232-member-settings-account-general-and-spec.md D7).
|
|
5
|
+
*
|
|
6
|
+
* One of the account tree's TWO topology seams (roadmap D1; the other is
|
|
7
|
+
* `billing-source.ts`): no other file under the account tree tests
|
|
8
|
+
* `SIGN_IN_PROVIDER`, `TOPOLOGY` or `SIGN_IN_ENABLED`.
|
|
9
|
+
*
|
|
10
|
+
* - A spoke that signs in through its hub (`SIGN_IN_PROVIDER === "hub"`,
|
|
11
|
+
* #7894) answers `hub`, with the hub's host when it is configured.
|
|
12
|
+
* - Any other site answers `site`, with the sign-in methods this deployment
|
|
13
|
+
* has configured. Better Auth's session does not say which one this
|
|
14
|
+
* member used, so the list is the deployment's (C7).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { getActiveProviderIds } from "../../../../lib/auth-env";
|
|
18
|
+
import { hubSessionConfig } from "../../../../lib/hub-session/config";
|
|
19
|
+
import { SIGN_IN_PROVIDER } from "../../../../lib/topology.generated";
|
|
20
|
+
|
|
21
|
+
/** The sign-in methods General names, in the order it lists them. */
|
|
22
|
+
export const SIGN_IN_METHODS = ["google", "github", "magic-link", "email"] as const;
|
|
23
|
+
|
|
24
|
+
export type SignInMethodId = (typeof SIGN_IN_METHODS)[number];
|
|
25
|
+
|
|
26
|
+
export type SignInMethod =
|
|
27
|
+
| { kind: "hub"; hubHost: string | null }
|
|
28
|
+
| { kind: "site"; methods: SignInMethodId[] };
|
|
29
|
+
|
|
30
|
+
export function signInMethod(): SignInMethod {
|
|
31
|
+
if (SIGN_IN_PROVIDER === "hub") {
|
|
32
|
+
const config = hubSessionConfig();
|
|
33
|
+
return { kind: "hub", hubHost: config === null ? null : new URL(config.hubOrigin).host };
|
|
34
|
+
}
|
|
35
|
+
const active = new Set(getActiveProviderIds());
|
|
36
|
+
return { kind: "site", methods: SIGN_IN_METHODS.filter((id) => active.has(id)) };
|
|
37
|
+
}
|
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
* account/support-actions.ts — the account area's Support request action.
|
|
5
5
|
*
|
|
6
6
|
* `submitSupportRequest` (#7430, spec 7423 D11) — files a Support request
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* as a contact lead: `POST /v1/leads/submit` with `source: "web"`, the
|
|
8
|
+
* session's email and the payload `{ kind: "support", subject, message }`.
|
|
9
|
+
* Every request it files is `support`, whatever is posted (#8232 D12): an
|
|
10
|
+
* account-deletion request goes through `member.requestDeletion`
|
|
11
|
+
* (`profile-actions.ts`), which files it with the deployment that holds the
|
|
12
|
+
* account, so this action needs no topology test (SEC-7894-11). No `siteId` — the
|
|
10
13
|
* route resolves the tenant server-side and ignores a posted one (#6203).
|
|
11
14
|
* The request lands in the admin Inbox as a message. Not a `*Action`-suffixed
|
|
12
15
|
* export in a `*.actions.ts` file: lead capture already has its MCP twin
|
|
@@ -21,7 +24,7 @@
|
|
|
21
24
|
import { leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
22
25
|
import { headers } from "next/headers";
|
|
23
26
|
|
|
24
|
-
import {
|
|
27
|
+
import { readSession } from "../../../../lib/topology.generated";
|
|
25
28
|
|
|
26
29
|
/** PII-safe error identity for logs (the `checkout.actions.ts` idiom: `err.name` only). */
|
|
27
30
|
function errorName(err: unknown): string {
|
|
@@ -53,7 +56,7 @@ function isNotConfigured(res: { status: number; body: unknown }): boolean {
|
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
/**
|
|
56
|
-
* Files the signed-in member's Support
|
|
59
|
+
* Files the signed-in member's Support request. The email is
|
|
57
60
|
* the session's own — never a form field — so a request always names the
|
|
58
61
|
* person who sent it.
|
|
59
62
|
*/
|
|
@@ -61,10 +64,6 @@ export async function submitSupportRequest(
|
|
|
61
64
|
_prev: SupportRequestState,
|
|
62
65
|
formData: FormData,
|
|
63
66
|
): Promise<SupportRequestState> {
|
|
64
|
-
// A hub sign-in spoke's account belongs to the hub, whose own settings take
|
|
65
|
-
// a deletion request (#7894 D10, SEC-7894-11): here it can only be support.
|
|
66
|
-
const kind =
|
|
67
|
-
field(formData, "kind") === "deletion" && SIGN_IN_PROVIDER !== "hub" ? "deletion" : "support";
|
|
68
67
|
const subject = field(formData, "subject");
|
|
69
68
|
const message = field(formData, "message");
|
|
70
69
|
if (!subject || !message || subject.length > MAX_SUBJECT || message.length > MAX_MESSAGE) {
|
|
@@ -79,7 +78,7 @@ export async function submitSupportRequest(
|
|
|
79
78
|
const body = {
|
|
80
79
|
source: "web",
|
|
81
80
|
email: session.user.email,
|
|
82
|
-
payload: { kind, subject, message },
|
|
81
|
+
payload: { kind: "support", subject, message },
|
|
83
82
|
};
|
|
84
83
|
// #7394 — a spoke counts the request against the visitor's own budget.
|
|
85
84
|
const forwarded = visitorForwardHeaders(
|
|
@@ -27,6 +27,7 @@ import "../globals.css";
|
|
|
27
27
|
|
|
28
28
|
import { Telemetry, VerificationMeta } from "@working-theory/analytics";
|
|
29
29
|
import { MarketingShell } from "@working-theory/templates/shells";
|
|
30
|
+
import { themeToShellCss } from "@working-theory/theme";
|
|
30
31
|
import { Link, isRtl, localeHref } from "@working-theory/ui";
|
|
31
32
|
import { BRAND_PATHS, LocaleSwitcherNav } from "@working-theory/ui/blocks";
|
|
32
33
|
import {
|
|
@@ -58,7 +59,11 @@ import { assertEnabledLocale } from "../../lib/locale-guard";
|
|
|
58
59
|
import { footerColumns, navItems } from "../../lib/nav-config";
|
|
59
60
|
import { getOgImageUrl } from "../../lib/og";
|
|
60
61
|
import { backLinkHref } from "../../lib/profile-back-link";
|
|
61
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
COPYRIGHT_HOLDER,
|
|
64
|
+
SITE_DESCRIPTION,
|
|
65
|
+
SITE_NAME,
|
|
66
|
+
} from "../../lib/site-brand";
|
|
62
67
|
import { siteChrome, type SiteChrome } from "../../lib/site-chrome";
|
|
63
68
|
import { siteLogo } from "../../lib/site-logo";
|
|
64
69
|
// Registers the `_site.mdx` identity with page-renderer at module load, so
|
|
@@ -126,6 +131,11 @@ const NAV_CLIENT_ACTION_FLOW: NavActionFlow["flow"] = {
|
|
|
126
131
|
action: "#",
|
|
127
132
|
};
|
|
128
133
|
|
|
134
|
+
// The member account shell's token reset (#8337 D3) — a package constant
|
|
135
|
+
// (no site, request or user input), computed once at module scope and
|
|
136
|
+
// emitted only on account routes, after `theme-vars`, below.
|
|
137
|
+
const SHELL_CSS = themeToShellCss();
|
|
138
|
+
|
|
129
139
|
// ── Theme helpers ────────────────────────────────────────────────────────────
|
|
130
140
|
|
|
131
141
|
/**
|
|
@@ -214,10 +224,10 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
214
224
|
|
|
215
225
|
return {
|
|
216
226
|
title: SITE_NAME,
|
|
217
|
-
description:
|
|
227
|
+
description: SITE_DESCRIPTION,
|
|
218
228
|
openGraph: {
|
|
219
229
|
title: SITE_NAME,
|
|
220
|
-
description:
|
|
230
|
+
description: SITE_DESCRIPTION,
|
|
221
231
|
type: "website",
|
|
222
232
|
url: baseUrl,
|
|
223
233
|
images: [ogImageUrl],
|
|
@@ -225,7 +235,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
225
235
|
twitter: {
|
|
226
236
|
card: "summary_large_image",
|
|
227
237
|
title: SITE_NAME,
|
|
228
|
-
description:
|
|
238
|
+
description: SITE_DESCRIPTION,
|
|
229
239
|
images: [ogImageUrl],
|
|
230
240
|
},
|
|
231
241
|
...getStealthRobots(),
|
|
@@ -617,6 +627,15 @@ export default async function LocaleLayout({
|
|
|
617
627
|
data-ailk="theme-vars"
|
|
618
628
|
dangerouslySetInnerHTML={{ __html: themeHead.css }}
|
|
619
629
|
/>
|
|
630
|
+
{/* The member account shell's token reset (#8337 D3) — account
|
|
631
|
+
routes only, after theme-vars, so :has([data-ailk-shell])'s
|
|
632
|
+
higher specificity wins whatever a browser's :has() support. */}
|
|
633
|
+
{isAccountRoute ? (
|
|
634
|
+
<style
|
|
635
|
+
data-ailk="shell-vars"
|
|
636
|
+
dangerouslySetInnerHTML={{ __html: SHELL_CSS }}
|
|
637
|
+
/>
|
|
638
|
+
) : null}
|
|
620
639
|
{/* Favicons — single brand mark (theme-independent), PNG fallbacks, ICO legacy */}
|
|
621
640
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
622
641
|
<link
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The hub's sign-in continuation module (#8146 D3; spec AC-3(b), AC-4).
|
|
7
|
+
* Pure functions over `seal.ts`; no mocks.
|
|
8
|
+
*/
|
|
9
|
+
import { seal } from "../../../../../lib/hub-session/seal";
|
|
10
|
+
import {
|
|
11
|
+
CONTINUATION_REF_MAX,
|
|
12
|
+
CONTINUATION_TTL_SECONDS,
|
|
13
|
+
CONTINUE_PATH,
|
|
14
|
+
mintContinuation,
|
|
15
|
+
openContinuation,
|
|
16
|
+
} from "../continuation";
|
|
17
|
+
|
|
18
|
+
const SECRET = "k".repeat(43);
|
|
19
|
+
const T = 1_790_000_000_000;
|
|
20
|
+
|
|
21
|
+
const Q = new URLSearchParams({
|
|
22
|
+
response_type: "code",
|
|
23
|
+
client_id: "spoke_abc",
|
|
24
|
+
redirect_uri: "https://spoke.example.com/api/hub-session/callback",
|
|
25
|
+
state: "s".repeat(43),
|
|
26
|
+
code_challenge: "c".repeat(43),
|
|
27
|
+
code_challenge_method: "S256",
|
|
28
|
+
}).toString();
|
|
29
|
+
|
|
30
|
+
describe("constants", () => {
|
|
31
|
+
it("pins the path, the ref cap and the 600 s life", () => {
|
|
32
|
+
expect(CONTINUE_PATH).toBe("/api/auth/hub/continue");
|
|
33
|
+
expect(CONTINUATION_REF_MAX).toBe(4096);
|
|
34
|
+
expect(CONTINUATION_TTL_SECONDS).toBe(600);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("AC-4 — the continuation lives 600 s and round-trips", () => {
|
|
39
|
+
it("opens to the query it was minted from, up to the last millisecond", () => {
|
|
40
|
+
const ref = mintContinuation(Q, SECRET, T);
|
|
41
|
+
expect(openContinuation(ref, SECRET, T + 599_000)).toBe(Q);
|
|
42
|
+
expect(openContinuation(ref, SECRET, T + 599_999)).toBe(Q);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("is gone at exactly 600 s (exp <= now)", () => {
|
|
46
|
+
const ref = mintContinuation(Q, SECRET, T);
|
|
47
|
+
expect(openContinuation(ref, SECRET, T + 600_000)).toBeNull();
|
|
48
|
+
expect(openContinuation(ref, SECRET, T + 600_001)).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("is base64url only, so no decode can change it", () => {
|
|
52
|
+
const ref = mintContinuation(Q, SECRET, T);
|
|
53
|
+
expect(ref).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
54
|
+
expect(decodeURIComponent(decodeURIComponent(ref))).toBe(ref);
|
|
55
|
+
expect(ref.length).toBeLessThan(CONTINUATION_REF_MAX);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("AC-3(b) — a ref that is not ours opens to null", () => {
|
|
60
|
+
const good = mintContinuation(Q, SECRET, T);
|
|
61
|
+
|
|
62
|
+
it("flips the first character", () => {
|
|
63
|
+
const flipped = (good[0] === "A" ? "B" : "A") + good.slice(1);
|
|
64
|
+
expect(openContinuation(flipped, SECRET, T)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("sealed under another secret", () => {
|
|
68
|
+
const other = mintContinuation(Q, "x".repeat(43), T);
|
|
69
|
+
expect(openContinuation(other, SECRET, T)).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('a valid payload sealed with purpose "flow" and the SAME AAD (only the key differs)', () => {
|
|
73
|
+
const forged = seal(
|
|
74
|
+
{ v: 1, q: Q, exp: T + 600_000 },
|
|
75
|
+
SECRET,
|
|
76
|
+
"flow",
|
|
77
|
+
"hub-continue",
|
|
78
|
+
);
|
|
79
|
+
expect(openContinuation(forged, SECRET, T)).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("a value sealed under another name never opens as a continuation", () => {
|
|
83
|
+
const forged = seal(
|
|
84
|
+
{ v: 1, q: Q, exp: T + 600_000 },
|
|
85
|
+
SECRET,
|
|
86
|
+
"continue",
|
|
87
|
+
"ailk-hub-flow",
|
|
88
|
+
);
|
|
89
|
+
expect(openContinuation(forged, SECRET, T)).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("exp in the past", () => {
|
|
93
|
+
const expired = seal(
|
|
94
|
+
{ v: 1, q: Q, exp: T - 1 },
|
|
95
|
+
SECRET,
|
|
96
|
+
"continue",
|
|
97
|
+
"hub-continue",
|
|
98
|
+
);
|
|
99
|
+
expect(openContinuation(expired, SECRET, T)).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("4097 characters", () => {
|
|
103
|
+
expect(openContinuation("A".repeat(4097), SECRET, T)).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("a character outside base64url", () => {
|
|
107
|
+
expect(openContinuation(`${good}%`, SECRET, T)).toBeNull();
|
|
108
|
+
expect(openContinuation(`${good}?x=1`, SECRET, T)).toBeNull();
|
|
109
|
+
expect(openContinuation("", SECRET, T)).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("the base64url of plain JSON, unsealed", () => {
|
|
113
|
+
const plain = Buffer.from(
|
|
114
|
+
JSON.stringify({ v: 1, q: Q, exp: T + 600_000 }),
|
|
115
|
+
).toString("base64url");
|
|
116
|
+
expect(openContinuation(plain, SECRET, T)).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it.each([
|
|
120
|
+
["a wrong version", { v: 2, q: Q, exp: T + 1000 }],
|
|
121
|
+
["an empty q", { v: 1, q: "", exp: T + 1000 }],
|
|
122
|
+
["a non-string q", { v: 1, q: 5, exp: T + 1000 }],
|
|
123
|
+
["a non-number exp", { v: 1, q: Q, exp: "later" }],
|
|
124
|
+
["a bare string", "hello"],
|
|
125
|
+
["null", null],
|
|
126
|
+
])("a sealed value with %s", (_name, value) => {
|
|
127
|
+
const ref = seal(value, SECRET, "continue", "hub-continue");
|
|
128
|
+
expect(openContinuation(ref, SECRET, T)).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("re-serializes a forged q so a CR/LF is percent-encoded and never throws", () => {
|
|
132
|
+
const ref = seal(
|
|
133
|
+
{ v: 1, q: "a=1\r\nX-Injected: 1", exp: T + 600_000 },
|
|
134
|
+
SECRET,
|
|
135
|
+
"continue",
|
|
136
|
+
"hub-continue",
|
|
137
|
+
);
|
|
138
|
+
const opened = openContinuation(ref, SECRET, T);
|
|
139
|
+
expect(opened).not.toBeNull();
|
|
140
|
+
expect(opened).not.toMatch(/[\r\n]/);
|
|
141
|
+
expect(() => new Headers({ Location: `/x?${opened}` })).not.toThrow();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -15,12 +15,14 @@
|
|
|
15
15
|
// --- Mocks set up BEFORE imports ---
|
|
16
16
|
|
|
17
17
|
const mockGetSession = jest.fn();
|
|
18
|
+
const mockSecret = "k".repeat(43);
|
|
18
19
|
// eslint-disable-next-line no-restricted-syntax -- auth.ts is the Better Auth singleton (its adapter needs a live database); this stand-in for the session read is the repo-standard double for that boundary (see the sibling suites under app/[locale]/(authed)/account/__tests__).
|
|
19
20
|
jest.mock("../../../../../../auth", () => ({
|
|
20
21
|
__esModule: true,
|
|
21
22
|
auth: {
|
|
22
23
|
api: { getSession: (...args: unknown[]) => mockGetSession(...args) },
|
|
23
24
|
},
|
|
25
|
+
resolveSecret: () => mockSecret,
|
|
24
26
|
}));
|
|
25
27
|
|
|
26
28
|
const mockCreateCode = jest.fn();
|
|
@@ -30,9 +32,11 @@ jest.mock("@working-theory/api-client", () => ({
|
|
|
30
32
|
}));
|
|
31
33
|
|
|
32
34
|
import { resolveNextPath } from "../../../../../[locale]/(auth)/sign-in/next-path";
|
|
35
|
+
import { openContinuation } from "../../continuation";
|
|
33
36
|
import { GET } from "../route";
|
|
34
37
|
|
|
35
38
|
const HUB = "https://hub.example.com";
|
|
39
|
+
const CONTINUE = "/api/auth/hub/continue";
|
|
36
40
|
const SPOKE_CALLBACK = "https://spoke.example.com/api/hub-session/callback";
|
|
37
41
|
const CHALLENGE = "c".repeat(43);
|
|
38
42
|
const STATE = "s".repeat(43);
|
|
@@ -102,34 +106,82 @@ describe("a malformed query is a 400 and never a redirect", () => {
|
|
|
102
106
|
});
|
|
103
107
|
|
|
104
108
|
describe("no Better Auth session", () => {
|
|
105
|
-
|
|
109
|
+
/** The `next` value of the 303, and the ref inside it. */
|
|
110
|
+
function continuationOf(res: Response): { next: string; ref: string } {
|
|
111
|
+
const location = res.headers.get("location") ?? "";
|
|
112
|
+
const next = new URL(location, HUB).searchParams.get("next") ?? "";
|
|
113
|
+
return { next, ref: next.slice(`${CONTINUE}/`.length) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
it("#8146 AC-1(b): 303 to /sign-in?next= followed by the encoded continue path and a sealed ref", async () => {
|
|
106
117
|
mockGetSession.mockResolvedValue(null);
|
|
107
118
|
const search = query();
|
|
108
119
|
|
|
109
120
|
const res = await GET(request(search));
|
|
110
121
|
|
|
111
122
|
expect(res.status).toBe(303);
|
|
112
|
-
const
|
|
113
|
-
expect(location).toBe(
|
|
114
|
-
`/sign-in?next=${encodeURIComponent(
|
|
123
|
+
const { next, ref } = continuationOf(res);
|
|
124
|
+
expect(res.headers.get("location")).toBe(
|
|
125
|
+
`/sign-in?next=${encodeURIComponent(`${CONTINUE}/${ref}`)}`,
|
|
115
126
|
);
|
|
127
|
+
expect(next).toBe(`${CONTINUE}/${ref}`);
|
|
128
|
+
expect(openContinuation(ref, mockSecret)).toBe(search);
|
|
129
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
130
|
+
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
|
|
116
131
|
expect(mockCreateCode).not.toHaveBeenCalled();
|
|
117
132
|
});
|
|
118
133
|
|
|
119
|
-
it("AC-
|
|
134
|
+
it("#8146 AC-1(b): the next value is a same-origin path the sign-in card keeps (resolveNextPath) and a second decode cannot change", async () => {
|
|
120
135
|
mockGetSession.mockResolvedValue(null);
|
|
121
136
|
|
|
122
137
|
const res = await GET(request(query()));
|
|
123
138
|
|
|
124
|
-
const next =
|
|
125
|
-
|
|
126
|
-
"next",
|
|
127
|
-
) ?? "";
|
|
128
|
-
expect(next.startsWith("/api/auth/hub/authorize?")).toBe(true);
|
|
139
|
+
const { next } = continuationOf(res);
|
|
140
|
+
expect(next.startsWith(`${CONTINUE}/`)).toBe(true);
|
|
129
141
|
// Unchanged — not the fallback — so Better Auth is handed it as the callback
|
|
130
142
|
// for Google and for the emailed link alike.
|
|
131
143
|
expect(resolveNextPath(next, "/account")).toBe(next);
|
|
132
144
|
expect(next).not.toMatch(/^https?:/);
|
|
145
|
+
// Better Auth decodes the callback a second time: nothing may change.
|
|
146
|
+
expect(next).toMatch(/^[A-Za-z0-9_/-]+$/);
|
|
147
|
+
expect(decodeURIComponent(decodeURIComponent(next))).toBe(next);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("#8146 AC-1(b): the ref holds the canonical six-key query — a reversed query with an extra key opens to the same string, without it", async () => {
|
|
151
|
+
mockGetSession.mockResolvedValue(null);
|
|
152
|
+
const canonical = query();
|
|
153
|
+
const reversed = [...new URLSearchParams(canonical).entries()]
|
|
154
|
+
.reverse()
|
|
155
|
+
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
|
156
|
+
.join("&");
|
|
157
|
+
|
|
158
|
+
const res = await GET(request(`${reversed}&extra=1`));
|
|
159
|
+
|
|
160
|
+
expect(res.status).toBe(303);
|
|
161
|
+
const opened = openContinuation(continuationOf(res).ref, mockSecret);
|
|
162
|
+
expect(opened).toBe(canonical);
|
|
163
|
+
expect(opened).not.toContain("extra");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("#8146 AC-3(b): a redirect_uri whose ref would pass 4096 characters is a 400 with NO Location", async () => {
|
|
167
|
+
mockGetSession.mockResolvedValue(null);
|
|
168
|
+
const redirectUri = "https://spoke.example.com/".padEnd(2048, "/");
|
|
169
|
+
|
|
170
|
+
const res = await GET(request(query({ redirect_uri: redirectUri })));
|
|
171
|
+
|
|
172
|
+
expect(res.status).toBe(400);
|
|
173
|
+
expect(res.headers.get("location")).toBeNull();
|
|
174
|
+
expect(await res.json()).toEqual({ error: "invalid_request" });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("#8146 AC-3(b): a 2048-character redirect_uri whose ref stays under the cap is a normal 303", async () => {
|
|
178
|
+
mockGetSession.mockResolvedValue(null);
|
|
179
|
+
const redirectUri = "https://spoke.example.com/".padEnd(2048, "a");
|
|
180
|
+
|
|
181
|
+
const res = await GET(request(query({ redirect_uri: redirectUri })));
|
|
182
|
+
|
|
183
|
+
expect(res.status).toBe(303);
|
|
184
|
+
expect(res.headers.get("location")).toMatch(/^\/sign-in\?next=/);
|
|
133
185
|
});
|
|
134
186
|
});
|
|
135
187
|
|
|
@@ -13,11 +13,15 @@
|
|
|
13
13
|
* &code_challenge=…&code_challenge_method=S256
|
|
14
14
|
*
|
|
15
15
|
* - a malformed query is a 400 and NEVER a redirect;
|
|
16
|
-
* - no Better Auth session:
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* - no Better Auth session: the six validated keys are sealed into a short
|
|
17
|
+
* ref and the answer is a 303 to `/sign-in?next=<the same-origin path
|
|
18
|
+
* /api/auth/hub/continue/<ref>>` (#8146). `resolveNextPath` keeps that
|
|
19
|
+
* path and the existing sign-in card hands it to Better Auth as the
|
|
20
|
+
* callback for Google and for the emailed link alike; the continue route
|
|
21
|
+
* then returns the visitor here. This route's own URL is NOT the callback:
|
|
22
|
+
* Better Auth's magic-link verify decodes a callback twice, which turns the
|
|
23
|
+
* encoded `redirect_uri` inside it into a URL its origin check refuses. A
|
|
24
|
+
* ref longer than `CONTINUATION_REF_MAX` is a 400, never a redirect;
|
|
21
25
|
* - a session: the API mints a single-use code, and this route redirects to
|
|
22
26
|
* the REGISTERED redirect URI the API echoes back, with `code` and the
|
|
23
27
|
* same `state` — never to the query's own `redirect_uri` until the API has
|
|
@@ -40,7 +44,12 @@
|
|
|
40
44
|
import { hubSignIn } from "@working-theory/api-client";
|
|
41
45
|
import { hubAuthorizeQuerySchema } from "@working-theory/validation";
|
|
42
46
|
|
|
43
|
-
import { auth } from "../../../../../auth";
|
|
47
|
+
import { auth, resolveSecret } from "../../../../../auth";
|
|
48
|
+
import {
|
|
49
|
+
CONTINUATION_REF_MAX,
|
|
50
|
+
CONTINUE_PATH,
|
|
51
|
+
mintContinuation,
|
|
52
|
+
} from "../continuation";
|
|
44
53
|
|
|
45
54
|
/** The six query keys, each read exactly once. */
|
|
46
55
|
const QUERY_KEYS = [
|
|
@@ -106,7 +115,13 @@ export async function GET(request: Request): Promise<Response> {
|
|
|
106
115
|
|
|
107
116
|
const session = await auth.api.getSession({ headers: request.headers });
|
|
108
117
|
if (!session) {
|
|
109
|
-
|
|
118
|
+
// Only the six validated keys, in QUERY_KEYS order: a ref carries nothing
|
|
119
|
+
// that did not pass the schema, and extra or reordered keys are dropped.
|
|
120
|
+
const canonical = new URLSearchParams();
|
|
121
|
+
for (const key of QUERY_KEYS) canonical.set(key, query[key]);
|
|
122
|
+
const ref = mintContinuation(canonical.toString(), resolveSecret());
|
|
123
|
+
if (ref.length > CONTINUATION_REF_MAX) return json(400, "invalid_request");
|
|
124
|
+
const next = `${CONTINUE_PATH}/${ref}`;
|
|
110
125
|
return see(`/sign-in?next=${encodeURIComponent(next)}`);
|
|
111
126
|
}
|
|
112
127
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's sign-in continuation (#8146, spec
|
|
3
|
+
* docs/specs/8146-email-link-sign-in-from-spec.md D2, D3).
|
|
4
|
+
*
|
|
5
|
+
* Better Auth's magic-link verify decodes `callbackURL` a second time, so a
|
|
6
|
+
* callback that holds an encoded `redirect_uri` is refused as
|
|
7
|
+
* INVALID_CALLBACK_URL. The authorize step therefore does not hand the sign-in
|
|
8
|
+
* page its own URL. It seals the validated authorize query into a short,
|
|
9
|
+
* opaque ref and hands over the same-origin path `/api/auth/hub/continue/<ref>`
|
|
10
|
+
* instead. A base64url string has no `%`, `?`, `&`, `=` or `:`, so no number of
|
|
11
|
+
* decodes changes it.
|
|
12
|
+
*
|
|
13
|
+
* Sealed (AES-256-GCM, key derived from the caller's secret under its own
|
|
14
|
+
* purpose), with its expiry inside. The ref is opaque but is NOT a secret: it
|
|
15
|
+
* carries what the cleartext `next` carried before (spec § 9 SEC-8146-1).
|
|
16
|
+
* Nothing here logs a ref.
|
|
17
|
+
*/
|
|
18
|
+
import { seal, unseal } from "../../../../lib/hub-session/seal";
|
|
19
|
+
|
|
20
|
+
/** The same-origin path a ref is appended to. */
|
|
21
|
+
export const CONTINUE_PATH = "/api/auth/hub/continue";
|
|
22
|
+
|
|
23
|
+
/** The longest ref the authorize step will mint, and the continue route open. */
|
|
24
|
+
export const CONTINUATION_REF_MAX = 4096;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 600 s, matching the spoke's flow cookie: a longer life is useless (the spoke
|
|
28
|
+
* refuses the callback once its flow expires) and it covers the 300 s emailed
|
|
29
|
+
* link plus time on the sign-in page.
|
|
30
|
+
*/
|
|
31
|
+
export const CONTINUATION_TTL_SECONDS = 600;
|
|
32
|
+
|
|
33
|
+
/** A fixed name: a value sealed for a cookie never opens here, nor the reverse. */
|
|
34
|
+
const AAD = "hub-continue";
|
|
35
|
+
|
|
36
|
+
const BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
37
|
+
|
|
38
|
+
/** `search` (the canonical authorize query, no leading `?`) as a sealed ref. */
|
|
39
|
+
export function mintContinuation(
|
|
40
|
+
search: string,
|
|
41
|
+
secret: string,
|
|
42
|
+
now: number = Date.now(),
|
|
43
|
+
): string {
|
|
44
|
+
return seal(
|
|
45
|
+
{ v: 1, q: search, exp: now + CONTINUATION_TTL_SECONDS * 1000 },
|
|
46
|
+
secret,
|
|
47
|
+
"continue",
|
|
48
|
+
AAD,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The authorize query a ref holds, or `null` when the ref is too long, is not
|
|
54
|
+
* base64url, does not open, is not the expected shape, or has expired.
|
|
55
|
+
*
|
|
56
|
+
* The query is re-serialized through `URLSearchParams`: for what
|
|
57
|
+
* `mintContinuation` wrote that is the same string, and for a `q` forged under
|
|
58
|
+
* a known secret it percent-encodes CR/LF so a `Location` can never be split
|
|
59
|
+
* and never throws (spec § 9 SEC-8146-7).
|
|
60
|
+
*/
|
|
61
|
+
export function openContinuation(
|
|
62
|
+
ref: string,
|
|
63
|
+
secret: string,
|
|
64
|
+
now: number = Date.now(),
|
|
65
|
+
): string | null {
|
|
66
|
+
if (ref.length > CONTINUATION_REF_MAX || !BASE64URL.test(ref)) return null;
|
|
67
|
+
const value = unseal(ref, secret, "continue", AAD);
|
|
68
|
+
if (typeof value !== "object" || value === null) return null;
|
|
69
|
+
const { v, q, exp } = value as Record<string, unknown>;
|
|
70
|
+
if (v !== 1 || typeof q !== "string" || q === "") return null;
|
|
71
|
+
if (typeof exp !== "number" || exp <= now) return null;
|
|
72
|
+
return new URLSearchParams(q).toString();
|
|
73
|
+
}
|