create-ailk 0.15.0 → 0.17.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/README.md +124 -0
- package/dist/add-module.js +52 -7
- package/dist/add-route.js +8 -0
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/cli.js +82 -2
- package/dist/derive-architecture.d.ts +22 -0
- package/dist/derive-architecture.js +58 -1
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +9 -0
- package/dist/lib/extract-module-bundle.js +18 -1
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.js +76 -0
- package/dist/parse-args.d.ts +5 -0
- package/dist/parse-args.js +17 -1
- package/dist/programmatic.d.ts +8 -0
- package/dist/programmatic.js +78 -9
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-manifest.d.ts +43 -0
- package/dist/site-manifest.js +98 -3
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +67 -0
- package/dist/spoke-checkout.js +562 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +23 -8
- package/dist/topology.d.ts +124 -9
- package/dist/topology.js +451 -40
- package/package.json +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +19 -4
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +89 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/spoke-store.ts +25 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/auth.ts +126 -0
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +7 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +72 -1
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +293 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +131 -2
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/layout.tsx +28 -15
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +57 -24
- package/templates/apps/web/app/openapi.json/route.ts +3 -6
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +33 -0
- package/templates/apps/web/lib/__tests__/server-api.test.ts +37 -28
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +3 -1
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/topology.generated.ts +15 -5
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/database/CHANGELOG.md +26 -0
- package/templates/database/__tests__/agents.db.test.ts +25 -5
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +3 -2
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +98 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -35
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// signed-out-gate.generated.tsx — what the (authed) group shows a signed-out visitor.
|
|
2
|
+
//
|
|
3
|
+
// GENERATED by create-ailk for this site's topology and sign-in mode (#7894).
|
|
4
|
+
// Do not edit: every scaffold rewrites it. A hub renders its own sign-in —
|
|
5
|
+
// the operator gate, or the account area's member gate; a spoke that signs in
|
|
6
|
+
// through its hub (`--hub-sign-in`) renders the hub-session gate.
|
|
7
|
+
import { localeHref } from "@working-theory/ui";
|
|
8
|
+
import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
|
|
9
|
+
import type { AuthPanelProvider } from "@working-theory/ui/sections";
|
|
10
|
+
import { readLocales } from "@working-theory/web-i18n";
|
|
11
|
+
import { getTranslations } from "next-intl/server";
|
|
12
|
+
import type { ReactElement } from "react";
|
|
13
|
+
|
|
14
|
+
import { resolveSignInCardText } from "../(auth)/sign-in/sign-in-text";
|
|
15
|
+
import { getActiveProviderIds } from "../../../lib/auth-env";
|
|
16
|
+
import { isAccountPath } from "../../../lib/auth-routes";
|
|
17
|
+
|
|
18
|
+
import { SignInGate } from "./sign-in-gate";
|
|
19
|
+
|
|
20
|
+
const SUPPORTED_PROVIDERS: readonly AuthPanelProvider[] = ["google", "github"];
|
|
21
|
+
|
|
22
|
+
export interface SignedOutGateProps {
|
|
23
|
+
locale: string;
|
|
24
|
+
/** The incoming request's headers (`x-pathname` names the requested page). */
|
|
25
|
+
requestHeaders: Headers;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function SignedOutGate({
|
|
29
|
+
locale,
|
|
30
|
+
requestHeaders,
|
|
31
|
+
}: SignedOutGateProps): Promise<ReactElement> {
|
|
32
|
+
// No `x-pathname`, no locale read: a render outside the middleware keeps
|
|
33
|
+
// the operator gate without touching the locale config.
|
|
34
|
+
const pathname = requestHeaders.get("x-pathname");
|
|
35
|
+
const isAccount =
|
|
36
|
+
pathname !== null && isAccountPath(pathname, readLocales());
|
|
37
|
+
const t = await getTranslations({
|
|
38
|
+
locale,
|
|
39
|
+
namespace: isAccount ? "account.gate" : "waitlistDashboard.gate",
|
|
40
|
+
});
|
|
41
|
+
const active = getActiveProviderIds();
|
|
42
|
+
const emailLink = active.includes("magic-link")
|
|
43
|
+
? { text: await resolveSignInCardText(locale) }
|
|
44
|
+
: undefined;
|
|
45
|
+
const providers = active.filter((id): id is AuthPanelProvider =>
|
|
46
|
+
(SUPPORTED_PROVIDERS as readonly string[]).includes(id),
|
|
47
|
+
);
|
|
48
|
+
const signInPath = localeHref(locale, "/sign-in");
|
|
49
|
+
|
|
50
|
+
if (isAccount && providers.length === 0 && !emailLink) {
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className="flex min-h-screen flex-col items-center justify-center gap-4 py-section text-center"
|
|
54
|
+
data-testid="account-sign-in-gate"
|
|
55
|
+
>
|
|
56
|
+
<Heading level={1} visual={4}>
|
|
57
|
+
{t("emptyHeadline")}
|
|
58
|
+
</Heading>
|
|
59
|
+
<Text variant="body-sm" tone="muted" className="max-w-xs">
|
|
60
|
+
{t("emptyDescription")}
|
|
61
|
+
</Text>
|
|
62
|
+
{/* With no sign-in method on, /sign-in has nothing for a member
|
|
63
|
+
either, so the one action goes back to the site. Outline
|
|
64
|
+
action, 44px on a phone and 36px from md up. */}
|
|
65
|
+
<Button asChild variant="secondary" className="max-md:min-h-11">
|
|
66
|
+
<Link
|
|
67
|
+
href={localeHref(locale, "/")}
|
|
68
|
+
variant="bare"
|
|
69
|
+
data-testid="account-sign-in-gate-home"
|
|
70
|
+
>
|
|
71
|
+
{t("homeLink")}
|
|
72
|
+
</Link>
|
|
73
|
+
</Button>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<SignInGate
|
|
80
|
+
labels={{
|
|
81
|
+
emptyHeadline: t("emptyHeadline"),
|
|
82
|
+
emptyDescription: t("emptyDescription"),
|
|
83
|
+
signInLinkLabel: t("signInLink"),
|
|
84
|
+
}}
|
|
85
|
+
signInPath={signInPath}
|
|
86
|
+
providers={providers}
|
|
87
|
+
{...(emailLink ? { emailLink } : {})}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -69,7 +69,12 @@ import "../../lib/site-identity";
|
|
|
69
69
|
// (#7521). Inert without AILK_LISTING_PUBLIC_KEY or the paid package.
|
|
70
70
|
import "../../lib/listings";
|
|
71
71
|
import { siteThemeHead } from "../../lib/site-theme-head";
|
|
72
|
-
import {
|
|
72
|
+
import {
|
|
73
|
+
SIGN_IN_PATH,
|
|
74
|
+
SIGN_OUT_ENDPOINT,
|
|
75
|
+
SIGN_UP_PATH,
|
|
76
|
+
readSession,
|
|
77
|
+
} from "../../lib/topology.generated";
|
|
73
78
|
import { resolveBaseUrl } from "../../src/lib/routes";
|
|
74
79
|
|
|
75
80
|
import { readSiteContactLines } from "./contact/site-contact-lines";
|
|
@@ -361,16 +366,22 @@ export default async function LocaleLayout({
|
|
|
361
366
|
// above).
|
|
362
367
|
// The signed-out auth pair, shared by the header (every page but the deck
|
|
363
368
|
// home) and the deck home's closing footer slide (#7718 D4).
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
369
|
+
// The pair comes from the generated seam (#7894 D10): a hub's /sign-in and
|
|
370
|
+
// /sign-up; a spoke that signs in through its hub has Sign in → /account
|
|
371
|
+
// (its gate starts the hub sign-in) and no Sign up; a spoke without sign-in
|
|
372
|
+
// (#7426 D11) has neither, so it offers no auth pair anywhere — not in the
|
|
373
|
+
// header, the deck's closing footer or a bare page's footer.
|
|
374
|
+
const authActions: readonly Extract<NavAction, { path: string }>[] = [
|
|
375
|
+
...(SIGN_IN_PATH !== null
|
|
368
376
|
? [
|
|
369
377
|
// NO_HAND_IMPLEMENT_OK: operator-directed live Free-template pass.
|
|
370
|
-
{ labelKey: "signIn", path:
|
|
371
|
-
{ labelKey: "signUp", path: "/sign-up", variant: "primary" },
|
|
378
|
+
{ labelKey: "signIn", path: SIGN_IN_PATH, variant: "outline-strong" as const },
|
|
372
379
|
]
|
|
373
|
-
: []
|
|
380
|
+
: []),
|
|
381
|
+
...(SIGN_UP_PATH !== null
|
|
382
|
+
? [{ labelKey: "signUp", path: SIGN_UP_PATH, variant: "primary" as const }]
|
|
383
|
+
: []),
|
|
384
|
+
];
|
|
374
385
|
const navActions: NavAction[] = session
|
|
375
386
|
? [
|
|
376
387
|
{ labelKey: "account", kind: "flow", flow: NAV_CLIENT_ACTION_FLOW },
|
|
@@ -416,12 +427,14 @@ export default async function LocaleLayout({
|
|
|
416
427
|
? []
|
|
417
428
|
: session
|
|
418
429
|
? [{ label: tNav("account"), href: localeHref(locale, "/account") }]
|
|
419
|
-
:
|
|
420
|
-
|
|
421
|
-
{ label: tNav("signIn"), href: localeHref(locale,
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
430
|
+
: [
|
|
431
|
+
...(SIGN_IN_PATH !== null
|
|
432
|
+
? [{ label: tNav("signIn"), href: localeHref(locale, SIGN_IN_PATH) }]
|
|
433
|
+
: []),
|
|
434
|
+
...(SIGN_UP_PATH !== null
|
|
435
|
+
? [{ label: tNav("signUp"), href: localeHref(locale, SIGN_UP_PATH) }]
|
|
436
|
+
: []),
|
|
437
|
+
];
|
|
425
438
|
|
|
426
439
|
// A flow action needs `renderFlowTrigger`, a function — and this layout is a
|
|
427
440
|
// Server Component. Nav forwards that function into NavMobileMenu ("use
|
|
@@ -749,7 +762,7 @@ export default async function LocaleLayout({
|
|
|
749
762
|
}}
|
|
750
763
|
/>
|
|
751
764
|
) : hasFlowAction ? (
|
|
752
|
-
<SignedInNav {...navProps} />
|
|
765
|
+
<SignedInNav {...navProps} signOutEndpoint={SIGN_OUT_ENDPOINT} />
|
|
753
766
|
) : (
|
|
754
767
|
<Nav
|
|
755
768
|
{...navProps}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests for the hub's authorize step (#7894, spec
|
|
7
|
+
* docs/specs/7894-the-hub-signs-in-its-spec.md D4; AC-4, AC-6).
|
|
8
|
+
*
|
|
9
|
+
* The Better Auth session read and `@working-theory/api-client`'s `hubSignIn`
|
|
10
|
+
* are the two external boundaries; both are mocked. The route's own decisions
|
|
11
|
+
* are what is under test: which query it accepts, who it sends to sign in,
|
|
12
|
+
* where a minted code goes, and what it refuses to redirect to.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// --- Mocks set up BEFORE imports ---
|
|
16
|
+
|
|
17
|
+
const mockGetSession = jest.fn();
|
|
18
|
+
// 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
|
+
jest.mock("../../../../../../auth", () => ({
|
|
20
|
+
__esModule: true,
|
|
21
|
+
auth: {
|
|
22
|
+
api: { getSession: (...args: unknown[]) => mockGetSession(...args) },
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const mockCreateCode = jest.fn();
|
|
27
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
28
|
+
__esModule: true,
|
|
29
|
+
hubSignIn: { createCode: (...args: unknown[]) => mockCreateCode(...args) },
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
import { resolveNextPath } from "../../../../../[locale]/(auth)/sign-in/next-path";
|
|
33
|
+
import { GET } from "../route";
|
|
34
|
+
|
|
35
|
+
const HUB = "https://hub.example.com";
|
|
36
|
+
const SPOKE_CALLBACK = "https://spoke.example.com/api/hub-session/callback";
|
|
37
|
+
const CHALLENGE = "c".repeat(43);
|
|
38
|
+
const STATE = "s".repeat(43);
|
|
39
|
+
const CODE = "k".repeat(43);
|
|
40
|
+
|
|
41
|
+
const ORIGINAL_ENV = process.env;
|
|
42
|
+
|
|
43
|
+
function query(overrides: Record<string, string | undefined> = {}): string {
|
|
44
|
+
const params = new URLSearchParams({
|
|
45
|
+
response_type: "code",
|
|
46
|
+
client_id: "spoke_abc",
|
|
47
|
+
redirect_uri: SPOKE_CALLBACK,
|
|
48
|
+
state: STATE,
|
|
49
|
+
code_challenge: CHALLENGE,
|
|
50
|
+
code_challenge_method: "S256",
|
|
51
|
+
});
|
|
52
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
53
|
+
if (value === undefined) params.delete(key);
|
|
54
|
+
else params.set(key, value);
|
|
55
|
+
}
|
|
56
|
+
return params.toString();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function request(
|
|
60
|
+
search: string,
|
|
61
|
+
headers: Record<string, string> = {},
|
|
62
|
+
): Request {
|
|
63
|
+
return new Request(`${HUB}/api/auth/hub/authorize?${search}`, { headers });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
jest.resetAllMocks();
|
|
68
|
+
process.env = { ...ORIGINAL_ENV, BETTER_AUTH_URL: `${HUB}/some/base` };
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterAll(() => {
|
|
72
|
+
process.env = ORIGINAL_ENV;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("a malformed query is a 400 and never a redirect", () => {
|
|
76
|
+
it.each([
|
|
77
|
+
["no client_id", { client_id: undefined }],
|
|
78
|
+
["response_type token", { response_type: "token" }],
|
|
79
|
+
["method plain", { code_challenge_method: "plain" }],
|
|
80
|
+
["a short code_challenge", { code_challenge: "short" }],
|
|
81
|
+
["no state", { state: undefined }],
|
|
82
|
+
["a state with a delimiter", { state: "a&b=c;d e" }],
|
|
83
|
+
["no redirect_uri", { redirect_uri: undefined }],
|
|
84
|
+
])("%s", async (_name, overrides) => {
|
|
85
|
+
mockGetSession.mockResolvedValue({ user: { id: "u1" } });
|
|
86
|
+
|
|
87
|
+
const res = await GET(request(query(overrides)));
|
|
88
|
+
|
|
89
|
+
expect(res.status).toBe(400);
|
|
90
|
+
expect(res.headers.get("location")).toBeNull();
|
|
91
|
+
expect(res.headers.get("content-type")).toBe("application/json");
|
|
92
|
+
expect(await res.json()).toEqual({ error: "invalid_request" });
|
|
93
|
+
expect(mockCreateCode).not.toHaveBeenCalled();
|
|
94
|
+
expect(mockGetSession).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("a repeated key is ambiguous, so 400", async () => {
|
|
98
|
+
const res = await GET(request(`${query()}&client_id=spoke_other`));
|
|
99
|
+
expect(res.status).toBe(400);
|
|
100
|
+
expect(res.headers.get("location")).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("no Better Auth session", () => {
|
|
105
|
+
it("AC-4: 303 to /sign-in?next= followed by the encoded authorize path and query", async () => {
|
|
106
|
+
mockGetSession.mockResolvedValue(null);
|
|
107
|
+
const search = query();
|
|
108
|
+
|
|
109
|
+
const res = await GET(request(search));
|
|
110
|
+
|
|
111
|
+
expect(res.status).toBe(303);
|
|
112
|
+
const location = res.headers.get("location") ?? "";
|
|
113
|
+
expect(location).toBe(
|
|
114
|
+
`/sign-in?next=${encodeURIComponent(`/api/auth/hub/authorize?${search}`)}`,
|
|
115
|
+
);
|
|
116
|
+
expect(mockCreateCode).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("AC-6: the next value is a same-origin path the sign-in card keeps (resolveNextPath)", async () => {
|
|
120
|
+
mockGetSession.mockResolvedValue(null);
|
|
121
|
+
|
|
122
|
+
const res = await GET(request(query()));
|
|
123
|
+
|
|
124
|
+
const next =
|
|
125
|
+
new URL(res.headers.get("location") ?? "", HUB).searchParams.get(
|
|
126
|
+
"next",
|
|
127
|
+
) ?? "";
|
|
128
|
+
expect(next.startsWith("/api/auth/hub/authorize?")).toBe(true);
|
|
129
|
+
// Unchanged — not the fallback — so Better Auth is handed it as the callback
|
|
130
|
+
// for Google and for the emailed link alike.
|
|
131
|
+
expect(resolveNextPath(next, "/account")).toBe(next);
|
|
132
|
+
expect(next).not.toMatch(/^https?:/);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("a signed-in person", () => {
|
|
137
|
+
beforeEach(() => {
|
|
138
|
+
mockGetSession.mockResolvedValue({ user: { id: "u1" } });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("AC-4: 303 to the REGISTERED redirect URI with the code and the same state", async () => {
|
|
142
|
+
mockCreateCode.mockResolvedValue({
|
|
143
|
+
ok: true,
|
|
144
|
+
body: { code: CODE, redirectUri: SPOKE_CALLBACK },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const res = await GET(
|
|
148
|
+
request(query(), { cookie: "better-auth.session_token=tok" }),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(res.status).toBe(303);
|
|
152
|
+
const target = new URL(res.headers.get("location") ?? "");
|
|
153
|
+
expect(`${target.origin}${target.pathname}`).toBe(SPOKE_CALLBACK);
|
|
154
|
+
expect(target.searchParams.get("code")).toBe(CODE);
|
|
155
|
+
expect(target.searchParams.get("state")).toBe(STATE);
|
|
156
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
157
|
+
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("forwards the visitor's cookie and the hub's pinned origin to the API — nothing else", async () => {
|
|
161
|
+
mockCreateCode.mockResolvedValue({
|
|
162
|
+
ok: true,
|
|
163
|
+
body: { code: CODE, redirectUri: SPOKE_CALLBACK },
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await GET(
|
|
167
|
+
request(query(), {
|
|
168
|
+
cookie: "better-auth.session_token=tok",
|
|
169
|
+
origin: "https://evil.example",
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
expect(mockCreateCode).toHaveBeenCalledWith(
|
|
174
|
+
{
|
|
175
|
+
clientId: "spoke_abc",
|
|
176
|
+
redirectUri: SPOKE_CALLBACK,
|
|
177
|
+
codeChallenge: CHALLENGE,
|
|
178
|
+
},
|
|
179
|
+
{ headers: { cookie: "better-auth.session_token=tok", origin: HUB } },
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("AC-4: a redirect_uri the API does not register is a 400 with NO Location header", async () => {
|
|
184
|
+
mockCreateCode.mockResolvedValue({
|
|
185
|
+
ok: false,
|
|
186
|
+
status: 400,
|
|
187
|
+
body: {
|
|
188
|
+
ok: false,
|
|
189
|
+
error: { code: "invalid_redirect_uri", message: "x" },
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const res = await GET(
|
|
194
|
+
request(
|
|
195
|
+
query({
|
|
196
|
+
redirect_uri: "https://attacker.example.com/api/hub-session/callback",
|
|
197
|
+
}),
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
expect(res.status).toBe(400);
|
|
202
|
+
expect(res.headers.get("location")).toBeNull();
|
|
203
|
+
expect(await res.json()).toEqual({ error: "invalid_request" });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("redirects to the URI the API echoed, never to the query's own", async () => {
|
|
207
|
+
// A (hypothetical) API answer for a different URI than the query named.
|
|
208
|
+
mockCreateCode.mockResolvedValue({
|
|
209
|
+
ok: true,
|
|
210
|
+
body: { code: CODE, redirectUri: SPOKE_CALLBACK },
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const res = await GET(
|
|
214
|
+
request(
|
|
215
|
+
query({
|
|
216
|
+
redirect_uri: "https://attacker.example.com/api/hub-session/callback",
|
|
217
|
+
}),
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
expect(new URL(res.headers.get("location") ?? "").origin).toBe(
|
|
222
|
+
"https://spoke.example.com",
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("answers 503, not a redirect, when the API echoes a URI that does not parse", async () => {
|
|
227
|
+
mockCreateCode.mockResolvedValue({
|
|
228
|
+
ok: true,
|
|
229
|
+
body: { code: CODE, redirectUri: "not a url" },
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const res = await GET(request(query()));
|
|
233
|
+
|
|
234
|
+
expect(res.status).toBe(503);
|
|
235
|
+
expect(res.headers.get("location")).toBeNull();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("AC-6: an unverified email goes to the hub's own /account/settings, never the spoke", async () => {
|
|
239
|
+
mockCreateCode.mockResolvedValue({
|
|
240
|
+
ok: false,
|
|
241
|
+
status: 403,
|
|
242
|
+
body: { ok: false, error: { code: "email_unverified", message: "x" } },
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const res = await GET(request(query()));
|
|
246
|
+
|
|
247
|
+
expect(res.status).toBe(303);
|
|
248
|
+
expect(res.headers.get("location")).toBe("/account/settings");
|
|
249
|
+
expect(res.headers.get("location")).not.toContain("spoke.example.com");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it.each([
|
|
253
|
+
["an unknown client", 400, "invalid_client"],
|
|
254
|
+
["a rate limit", 429, "rate_limited"],
|
|
255
|
+
["a forbidden caller", 403, "forbidden"],
|
|
256
|
+
["an expired session", 401, "unauthorized"],
|
|
257
|
+
])("%s is a 400, never a redirect", async (_name, status, code) => {
|
|
258
|
+
mockCreateCode.mockResolvedValue({
|
|
259
|
+
ok: false,
|
|
260
|
+
status,
|
|
261
|
+
body: { ok: false, error: { code, message: "x" } },
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const res = await GET(request(query()));
|
|
265
|
+
|
|
266
|
+
expect(res.status).toBe(400);
|
|
267
|
+
expect(res.headers.get("location")).toBeNull();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("answers 503 — not a redirect — when the API cannot be reached", async () => {
|
|
271
|
+
mockCreateCode.mockRejectedValue(new Error("network"));
|
|
272
|
+
|
|
273
|
+
const res = await GET(request(query()));
|
|
274
|
+
|
|
275
|
+
expect(res.status).toBe(503);
|
|
276
|
+
expect(res.headers.get("location")).toBeNull();
|
|
277
|
+
expect(await res.json()).toEqual({ error: "temporarily_unavailable" });
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it.each([
|
|
281
|
+
["unset", undefined],
|
|
282
|
+
["not a URL", "not a url"],
|
|
283
|
+
])(
|
|
284
|
+
"answers 503 when BETTER_AUTH_URL is %s (no pinned origin to send)",
|
|
285
|
+
async (_name, value) => {
|
|
286
|
+
if (value === undefined) delete process.env.BETTER_AUTH_URL;
|
|
287
|
+
else process.env.BETTER_AUTH_URL = value;
|
|
288
|
+
|
|
289
|
+
const res = await GET(request(query()));
|
|
290
|
+
|
|
291
|
+
expect(res.status).toBe(503);
|
|
292
|
+
expect(mockCreateCode).not.toHaveBeenCalled();
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's browser step of "sign in through the hub" (#7894, spec
|
|
3
|
+
* docs/specs/7894-the-hub-signs-in-its-spec.md D2, D4).
|
|
4
|
+
*
|
|
5
|
+
* A spoke redirects a visitor here; this route is the one place the person
|
|
6
|
+
* must be signed in on the HUB'S OWN origin, because that is where the Better
|
|
7
|
+
* Auth session cookie lives. It owns no stored artifact: codes and credentials
|
|
8
|
+
* live behind `apps/api` (`POST /v1/hub-sign-in/codes`), reached through
|
|
9
|
+
* `@working-theory/api-client` with the visitor's own cookie.
|
|
10
|
+
*
|
|
11
|
+
* GET /api/auth/hub/authorize
|
|
12
|
+
* ?response_type=code&client_id=…&redirect_uri=…&state=…
|
|
13
|
+
* &code_challenge=…&code_challenge_method=S256
|
|
14
|
+
*
|
|
15
|
+
* - a malformed query is a 400 and NEVER a redirect;
|
|
16
|
+
* - no Better Auth session: 303 to `/sign-in?next=<this path and query>` —
|
|
17
|
+
* a same-origin path, so `resolveNextPath` keeps it and the existing
|
|
18
|
+
* sign-in card hands it to Better Auth as the callback for Google and for
|
|
19
|
+
* the emailed link alike (the hub's own `magicLink` sends the link, and it
|
|
20
|
+
* returns through this step);
|
|
21
|
+
* - a session: the API mints a single-use code, and this route redirects to
|
|
22
|
+
* the REGISTERED redirect URI the API echoes back, with `code` and the
|
|
23
|
+
* same `state` — never to the query's own `redirect_uri` until the API has
|
|
24
|
+
* confirmed it is the client's;
|
|
25
|
+
* - a person whose email is not verified (403 `email_unverified`) goes to the
|
|
26
|
+
* hub's own `/account/settings`, whose For-you item says how to verify —
|
|
27
|
+
* never to the spoke;
|
|
28
|
+
* - any other refusal is a 400 with `{ "error": "invalid_request" }` and no
|
|
29
|
+
* visitor copy: only a misconfigured client reaches it.
|
|
30
|
+
*
|
|
31
|
+
* `BETTER_AUTH_URL` pins the `Origin` sent with the cookie-authenticated write
|
|
32
|
+
* (Node's `fetch` sets none). Unset, the step answers 503: it needs a pinned
|
|
33
|
+
* origin, exactly as the emailed link does.
|
|
34
|
+
*
|
|
35
|
+
* This is the second api-shaped route under `apps/web/app/api/auth/`, beside
|
|
36
|
+
* Better Auth's catch-all; it sits under `app/api/auth`, a spoke removal seed
|
|
37
|
+
* (spec 7426 D4), so no spoke ever carries it. Nothing here logs a code, a
|
|
38
|
+
* state, a challenge or a cookie.
|
|
39
|
+
*/
|
|
40
|
+
import { hubSignIn } from "@working-theory/api-client";
|
|
41
|
+
import { hubAuthorizeQuerySchema } from "@working-theory/validation";
|
|
42
|
+
|
|
43
|
+
import { auth } from "../../../../../auth";
|
|
44
|
+
|
|
45
|
+
/** The six query keys, each read exactly once. */
|
|
46
|
+
const QUERY_KEYS = [
|
|
47
|
+
"response_type",
|
|
48
|
+
"client_id",
|
|
49
|
+
"redirect_uri",
|
|
50
|
+
"state",
|
|
51
|
+
"code_challenge",
|
|
52
|
+
"code_challenge_method",
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
/** Where an unverified person is sent to verify: the hub's own account page. */
|
|
56
|
+
const VERIFY_EMAIL_PATH = "/account/settings";
|
|
57
|
+
|
|
58
|
+
function json(status: number, error: string): Response {
|
|
59
|
+
return new Response(JSON.stringify({ error }), {
|
|
60
|
+
status,
|
|
61
|
+
headers: {
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
"Cache-Control": "no-store",
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A 303 whose target may be a same-origin path or an absolute spoke URL. */
|
|
69
|
+
function see(location: string): Response {
|
|
70
|
+
return new Response(null, {
|
|
71
|
+
status: 303,
|
|
72
|
+
headers: {
|
|
73
|
+
Location: location,
|
|
74
|
+
"Cache-Control": "no-store",
|
|
75
|
+
// The code rides this URL; no page may leak it through a Referer.
|
|
76
|
+
"Referrer-Policy": "no-referrer",
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The pinned origin of the hub's own web app, or `null` when unset/invalid. */
|
|
82
|
+
function pinnedOrigin(): string | null {
|
|
83
|
+
const raw = process.env.BETTER_AUTH_URL;
|
|
84
|
+
if (!raw) return null;
|
|
85
|
+
try {
|
|
86
|
+
return new URL(raw).origin;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
|
|
93
|
+
export async function GET(request: Request): Promise<Response> {
|
|
94
|
+
const url = new URL(request.url);
|
|
95
|
+
|
|
96
|
+
// Each declared key exactly once: a repeated key is ambiguous, so 400.
|
|
97
|
+
const raw: Record<string, string | undefined> = {};
|
|
98
|
+
for (const key of QUERY_KEYS) {
|
|
99
|
+
const values = url.searchParams.getAll(key);
|
|
100
|
+
if (values.length > 1) return json(400, "invalid_request");
|
|
101
|
+
raw[key] = values[0];
|
|
102
|
+
}
|
|
103
|
+
const parsed = hubAuthorizeQuerySchema.safeParse(raw);
|
|
104
|
+
if (!parsed.success) return json(400, "invalid_request");
|
|
105
|
+
const query = parsed.data;
|
|
106
|
+
|
|
107
|
+
const session = await auth.api.getSession({ headers: request.headers });
|
|
108
|
+
if (!session) {
|
|
109
|
+
const next = `${url.pathname}${url.search}`;
|
|
110
|
+
return see(`/sign-in?next=${encodeURIComponent(next)}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const origin = pinnedOrigin();
|
|
114
|
+
if (origin === null) return json(503, "temporarily_unavailable");
|
|
115
|
+
|
|
116
|
+
let result: Awaited<ReturnType<typeof hubSignIn.createCode>>;
|
|
117
|
+
try {
|
|
118
|
+
result = await hubSignIn.createCode(
|
|
119
|
+
{
|
|
120
|
+
clientId: query.client_id,
|
|
121
|
+
redirectUri: query.redirect_uri,
|
|
122
|
+
codeChallenge: query.code_challenge,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
headers: {
|
|
126
|
+
cookie: request.headers.get("cookie") ?? "",
|
|
127
|
+
origin,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
} catch {
|
|
132
|
+
return json(503, "temporarily_unavailable");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (result.ok) {
|
|
136
|
+
// Redirect to the URI the API confirmed is registered for this client.
|
|
137
|
+
let target: URL;
|
|
138
|
+
try {
|
|
139
|
+
target = new URL(result.body.redirectUri);
|
|
140
|
+
} catch {
|
|
141
|
+
// A registered URI is validated at registration; an unparseable echo is
|
|
142
|
+
// a server fault, never something to redirect to.
|
|
143
|
+
return json(503, "temporarily_unavailable");
|
|
144
|
+
}
|
|
145
|
+
target.searchParams.set("code", result.body.code);
|
|
146
|
+
target.searchParams.set("state", query.state);
|
|
147
|
+
return see(target.toString());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const code = (result.body as { error?: { code?: unknown } } | null)?.error
|
|
151
|
+
?.code;
|
|
152
|
+
if (result.status === 403 && code === "email_unverified") {
|
|
153
|
+
return see(VERIFY_EMAIL_PATH);
|
|
154
|
+
}
|
|
155
|
+
return json(400, "invalid_request");
|
|
156
|
+
}
|