create-ailk 0.20.0 → 0.22.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 +21 -1
- package/component-catalog.json +220 -8
- package/component-catalog.md +21 -9
- package/dist/add-module.js +9 -5
- package/dist/apply-design.d.ts +9 -0
- package/dist/apply-design.js +78 -31
- package/dist/derive-architecture.d.ts +8 -1
- package/dist/derive-architecture.js +38 -3
- package/dist/design-home.d.ts +54 -11
- package/dist/design-home.js +160 -22
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.d.ts +6 -1
- package/dist/lib/design-checks.js +23 -2
- package/dist/lib/env-catalog.js +5 -1
- package/dist/module-architecture.js +4 -2
- package/dist/programmatic.js +63 -25
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/site-roster.d.ts +4 -2
- package/dist/site-roster.js +7 -5
- package/dist/spoke-checkout.d.ts +39 -18
- package/dist/spoke-checkout.js +177 -236
- package/dist/starter-grid.js +16 -0
- package/dist/surfaces.js +30 -14
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.js +124 -1
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +6 -3
- package/dist/topology.js +25 -1
- package/dist/update.d.ts +6 -1
- package/dist/update.js +125 -18
- package/package.json +3 -3
- package/site-templates/_base/ailk-template.json +2 -2
- package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
- package/templates/README.md +26 -25
- package/templates/apps/api/CLAUDE.md +20 -7
- package/templates/apps/api/jest.config.cjs +1 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/openapi/spec.ts +30 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/account.ts +266 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +161 -7
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/lead-routing.ts +337 -8
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/person.ts +40 -8
- package/templates/apps/api/src/services/plan-offerings.ts +59 -0
- package/templates/apps/api/src/services/project-listings.ts +6 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
- package/templates/apps/mcp/jest.config.cjs +1 -0
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/CLAUDE.md +139 -3
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
- package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/jest.config.cjs +1 -0
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/checkout-config.ts +27 -0
- package/templates/apps/web/lib/checkout.ts +93 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +192 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +40 -12
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/database/CHANGELOG.md +47 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/seed.test.ts +72 -2
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/jest.config.cjs +1 -0
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +60 -0
- package/templates/tools/jest/pin-timezone.cjs +34 -0
- package/versions.json +34 -34
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HubSignInGate — the signed-out account gate of a spoke that signs in
|
|
3
|
-
* through its hub (#7894 D10).
|
|
3
|
+
* through its hub (#7894 D10; #8790 AC-1/AC-2).
|
|
4
4
|
*
|
|
5
5
|
* A Server Component, rendered by the `(authed)` layout through the generated
|
|
6
6
|
* gate seam (`signed-out-gate.generated.tsx`, its `signIn: "hub"` render). It
|
|
@@ -9,16 +9,26 @@
|
|
|
9
9
|
* link is a plain anchor (the `Link` primitive), so nothing prefetches the
|
|
10
10
|
* route and starts a flow the visitor did not ask for.
|
|
11
11
|
*
|
|
12
|
+
* #8790 — the gate reads as one of the site's inner pages, not a bare
|
|
13
|
+
* full-height stop: the site's inner-page header (the back arrow at the start
|
|
14
|
+
* edge, the mark centred — the shape `Nav bar="bare"` draws, composed here
|
|
15
|
+
* from the same `NavBackLink` and `siteLogo()`), a heading at the label size
|
|
16
|
+
* (`heading-2`), and an optional quiet prospect line a site turns on by
|
|
17
|
+
* adding `account.gate.prospectPrompt` + `prospectLink` + `prospectHref`
|
|
18
|
+
* to its messages — absent, the shipped default, renders nothing.
|
|
19
|
+
* The document title is the account page's job (`generateMetadata` there
|
|
20
|
+
* reads the session and names this state "Sign in · <site>").
|
|
21
|
+
*
|
|
12
22
|
* Not configured (`hubSessionConfig()` is `null`: a client id, secret or
|
|
13
23
|
* session secret missing), it renders the account area's existing "sign-in
|
|
14
24
|
* isn't available yet" state, whose one action leads back to the site.
|
|
15
25
|
*/
|
|
16
|
-
|
|
17
26
|
import { localeHref } from "@working-theory/ui";
|
|
27
|
+
import { NavBackLink } from "@working-theory/ui/chrome";
|
|
18
28
|
import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
|
|
19
29
|
import { getTranslations } from "next-intl/server";
|
|
20
30
|
import type { ReactElement } from "react";
|
|
21
|
-
|
|
31
|
+
import { siteLogo } from "../site-logo";
|
|
22
32
|
import { hubSessionConfig } from "./config";
|
|
23
33
|
import { HUB_SESSION_FALLBACK_PATH } from "./session";
|
|
24
34
|
|
|
@@ -40,40 +50,86 @@ export async function HubSignInGate({
|
|
|
40
50
|
const next =
|
|
41
51
|
requestHeaders.get("x-pathname") ??
|
|
42
52
|
localeHref(locale, HUB_SESSION_FALLBACK_PATH);
|
|
53
|
+
const home = localeHref(locale, "/");
|
|
54
|
+
// The three prospect keys are optional extensions of the typed catalog (the
|
|
55
|
+
// shipped en.json carries none — every leaf must be a non-empty string), so
|
|
56
|
+
// presence is the switch; hence the cast on `has`.
|
|
57
|
+
type Key = Parameters<typeof t.has>[0];
|
|
58
|
+
const has = (key: string): boolean => t.has(key as Key);
|
|
59
|
+
const prospect =
|
|
60
|
+
has("prospectPrompt") && has("prospectLink") && has("prospectHref")
|
|
61
|
+
? {
|
|
62
|
+
prompt: t("prospectPrompt" as Key),
|
|
63
|
+
linkLabel: t("prospectLink" as Key),
|
|
64
|
+
href: t("prospectHref" as Key),
|
|
65
|
+
}
|
|
66
|
+
: null;
|
|
43
67
|
|
|
44
68
|
return (
|
|
45
69
|
<div
|
|
46
|
-
className="flex min-h-screen flex-col
|
|
70
|
+
className="flex min-h-screen flex-col"
|
|
47
71
|
data-testid="account-sign-in-gate"
|
|
48
72
|
>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
{/* The site's inner-page header: back arrow at the start, the mark centred (#8790 AC-1). */}
|
|
74
|
+
<header
|
|
75
|
+
className="grid h-16 grid-cols-[1fr_auto_1fr] items-center px-5 md:px-8"
|
|
76
|
+
data-testid="account-sign-in-gate-header"
|
|
77
|
+
>
|
|
78
|
+
<div className="justify-self-start">
|
|
79
|
+
<NavBackLink href={home} label={t("homeLink")} />
|
|
80
|
+
</div>
|
|
81
|
+
<Link
|
|
82
|
+
href={home}
|
|
83
|
+
variant="unstyled"
|
|
84
|
+
className="inline-flex h-7 items-center [&>img]:h-full [&>img]:w-auto [&>img]:object-contain"
|
|
85
|
+
data-testid="account-sign-in-gate-mark"
|
|
86
|
+
>
|
|
87
|
+
{siteLogo()}
|
|
88
|
+
</Link>
|
|
89
|
+
<div />
|
|
90
|
+
</header>
|
|
91
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-section text-center">
|
|
92
|
+
<Heading level={1} visual={2}>
|
|
93
|
+
{configured ? t("hubHeadline") : t("emptyHeadline")}
|
|
94
|
+
</Heading>
|
|
95
|
+
<Text variant="body-sm" tone="muted" className="max-w-xs">
|
|
96
|
+
{configured ? t("hubDescription") : t("emptyDescription")}
|
|
97
|
+
</Text>
|
|
98
|
+
{configured ? (
|
|
99
|
+
// The primary action: 44px on a phone, 36px from md up.
|
|
100
|
+
<Button asChild className="max-md:min-h-11">
|
|
101
|
+
<Link
|
|
102
|
+
href={`${HUB_SIGN_IN_ROUTE}?next=${encodeURIComponent(next)}`}
|
|
103
|
+
variant="bare"
|
|
104
|
+
data-testid="account-hub-sign-in"
|
|
105
|
+
>
|
|
106
|
+
{t("hubSignIn")}
|
|
107
|
+
</Link>
|
|
108
|
+
</Button>
|
|
109
|
+
) : (
|
|
110
|
+
<Button asChild variant="secondary" className="max-md:min-h-11">
|
|
111
|
+
<Link
|
|
112
|
+
href={home}
|
|
113
|
+
variant="bare"
|
|
114
|
+
data-testid="account-sign-in-gate-home"
|
|
115
|
+
>
|
|
116
|
+
{t("homeLink")}
|
|
117
|
+
</Link>
|
|
118
|
+
</Button>
|
|
119
|
+
)}
|
|
120
|
+
{configured && prospect ? (
|
|
121
|
+
// A wrapping div carries the hook: `Text` forwards no data-* attributes.
|
|
122
|
+
<div data-testid="account-sign-in-gate-prospect">
|
|
123
|
+
<Text as="p" variant="body-sm" tone="muted">
|
|
124
|
+
{prospect.prompt}{" "}
|
|
125
|
+
<Link href={prospect.href} variant="inline">
|
|
126
|
+
{prospect.linkLabel}
|
|
127
|
+
</Link>
|
|
128
|
+
.
|
|
129
|
+
</Text>
|
|
130
|
+
</div>
|
|
131
|
+
) : null}
|
|
132
|
+
</div>
|
|
77
133
|
</div>
|
|
78
134
|
);
|
|
79
135
|
}
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
* `lib/license-key-store.ts`, which hands `prismaForAuth.siteSetting` to the
|
|
10
10
|
* licence client's SiteSetting store for the ONE deployment-wide
|
|
11
11
|
* `license.renewed_key` row (#6677 DD-6 — a narrow, named exception).
|
|
12
|
+
*
|
|
13
|
+
* A third use rides on `auth.ts`'s own (#8765 D4, SEC-8765-K):
|
|
14
|
+
* `lib/sign-up-agreements.ts`'s account hook writes the new user's `sign_up`
|
|
15
|
+
* consent grants through `consentGrant.createMany` on the transaction client
|
|
16
|
+
* Better Auth's sign-up transaction runs on — only rows for the user that
|
|
17
|
+
* transaction is creating, never a read and never any other row. It imports
|
|
18
|
+
* nothing from here; it reaches the client through the adapter.
|
|
19
|
+
*
|
|
12
20
|
* Every other business-logic caller goes through apps/api, never the
|
|
13
21
|
* database directly (architecture.yaml `apps/web` may_not_import_from
|
|
14
22
|
* `database`; the single-file singleton here is the documented exception,
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-up agreements, refused and recorded on the server (#8765 D3, D4, spec
|
|
3
|
+
* docs/specs/8765-sign-up-refuses-without-the-spec.md).
|
|
4
|
+
*
|
|
5
|
+
* THREE PIECES, ONE FILE. `auth.ts` installs all three:
|
|
6
|
+
*
|
|
7
|
+
* signUpAgreementsPlugin() a Better Auth plugin. While `isSignUpOpen()`,
|
|
8
|
+
* `POST /sign-up/email` without every sign-up
|
|
9
|
+
* agreement in its `agreements` body key is
|
|
10
|
+
* refused `400 AGREEMENTS_REQUIRED` — BEFORE
|
|
11
|
+
* the handler's user lookup, so the answer is
|
|
12
|
+
* the same for a new and a taken address
|
|
13
|
+
* (NFR2). The OAuth callback only warms the
|
|
14
|
+
* evidence cache, outside the transaction.
|
|
15
|
+
* withAuthTransactionCapture() wraps the client the Prisma adapter holds,
|
|
16
|
+
* so the transaction client Better Auth's
|
|
17
|
+
* sign-up transaction runs on is reachable
|
|
18
|
+
* from a database hook.
|
|
19
|
+
* signUpAgreementHooks() two database hooks: the user hook decides
|
|
20
|
+
* whether this request's new user carries
|
|
21
|
+
* agreements; the account hook writes one
|
|
22
|
+
* `sign_up` grant per agreement on the
|
|
23
|
+
* captured transaction client, so the User,
|
|
24
|
+
* its Account and the grants commit together
|
|
25
|
+
* or not at all (NFR1).
|
|
26
|
+
*
|
|
27
|
+
* THE EVIDENCE IS THE API'S. Each grant's statement, hash and version come
|
|
28
|
+
* from `GET /v1/consent/sign-up` — built by the same functions `apps/api`'s
|
|
29
|
+
* `services/consent.ts` stamps with — never from this app's copy of the
|
|
30
|
+
* registry. It is cached per process: fresh for 60 s, the last good entry
|
|
31
|
+
* served for up to 10 minutes while a refresh fails, at most one entry per
|
|
32
|
+
* enabled locale (SEC-8765-D). No usable entry → `503
|
|
33
|
+
* AGREEMENTS_UNAVAILABLE`: no user is created without evidence. The database
|
|
34
|
+
* hooks run INSIDE the transaction and never call the network; they read the
|
|
35
|
+
* cache only (SEC-8765-I).
|
|
36
|
+
*
|
|
37
|
+
* THE SECOND LEDGER WRITER. `services/consent.ts` writes every other row.
|
|
38
|
+
* This hook writes only `sign_up` rows for a user it is creating in the same
|
|
39
|
+
* transaction, so `consent.ts`'s advisory lock and write-only-on-change rule
|
|
40
|
+
* have nothing to guard here. It reaches the database through the auth
|
|
41
|
+
* client apps/web already holds as its one documented exception
|
|
42
|
+
* (`lib/prisma-for-auth.ts`), and imports no `@working-theory/database`
|
|
43
|
+
* value.
|
|
44
|
+
*
|
|
45
|
+
* NOT EVERY NEW USER HAS A GRANT (SEC-8765-C). A first social sign-in from
|
|
46
|
+
* `/sign-in`, or the `idToken` branch, carries no agreements; Better Auth
|
|
47
|
+
* still creates the user, and the account interstitial asks before the
|
|
48
|
+
* account loads.
|
|
49
|
+
*
|
|
50
|
+
* `better-auth/api` is imported lazily, inside the hooks: it is ESM-only, and
|
|
51
|
+
* the CommonJS Jest suites that build `createAuth()` mock only the
|
|
52
|
+
* `better-auth` modules `auth.ts` itself imports. Precedent for the file's
|
|
53
|
+
* shape: `lib/auth-email.ts`.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
57
|
+
|
|
58
|
+
import { consent } from "@working-theory/api-client";
|
|
59
|
+
import {
|
|
60
|
+
consentSignUpResponseSchema,
|
|
61
|
+
SIGN_UP_AGREEMENTS_FIELD,
|
|
62
|
+
signUpAgreementsSchema,
|
|
63
|
+
type ConsentSignUpResponse,
|
|
64
|
+
} from "@working-theory/validation";
|
|
65
|
+
import type { BetterAuthOptions, BetterAuthPlugin } from "better-auth";
|
|
66
|
+
|
|
67
|
+
import { isSignUpOpen } from "./auth-env";
|
|
68
|
+
|
|
69
|
+
const SIGN_UP_PATH = "/sign-up/email";
|
|
70
|
+
const CALLBACK_PREFIX = "/callback/";
|
|
71
|
+
const DEFAULT_LOCALE = "en";
|
|
72
|
+
const FRESH_MS = 60_000;
|
|
73
|
+
const STALE_MS = 10 * 60_000;
|
|
74
|
+
/** The longest one evidence read may hold a sign-up or a sign-in. */
|
|
75
|
+
const READ_TIMEOUT_MS = 2_000;
|
|
76
|
+
|
|
77
|
+
// ── The evidence cache ──────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
interface CacheEntry {
|
|
80
|
+
evidence: ConsentSignUpResponse;
|
|
81
|
+
fetchedAt: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const cache = new Map<string, CacheEntry>();
|
|
85
|
+
const refreshing = new Map<string, Promise<ConsentSignUpResponse | null>>();
|
|
86
|
+
let clock: () => number = Date.now;
|
|
87
|
+
|
|
88
|
+
/** Test seam: empties the per-process cache and sets the clock it reads. */
|
|
89
|
+
export function resetSignUpEvidenceCache(now: () => number = Date.now): void {
|
|
90
|
+
cache.clear();
|
|
91
|
+
refreshing.clear();
|
|
92
|
+
clock = now;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A body or state `locale` counts only when it is one of the site's enabled
|
|
97
|
+
* locales — the list `middleware.ts` routes. Anything else reads as `en`, so
|
|
98
|
+
* the cache holds at most one entry per enabled locale (SEC-8765-D).
|
|
99
|
+
*/
|
|
100
|
+
function normaliseLocale(raw: unknown): string {
|
|
101
|
+
return typeof raw === "string" && enabledLocales().includes(raw)
|
|
102
|
+
? raw
|
|
103
|
+
: DEFAULT_LOCALE;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function enabledLocales(): string[] {
|
|
107
|
+
const enabled = (process.env.NEXT_PUBLIC_ENABLED_LOCALES ?? DEFAULT_LOCALE)
|
|
108
|
+
.split(",")
|
|
109
|
+
.map((locale) => locale.trim())
|
|
110
|
+
.filter((locale) => locale.length > 0);
|
|
111
|
+
return enabled.length > 0 ? enabled : [DEFAULT_LOCALE];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One read of the API's evidence. Never throws: a failure is `null`. */
|
|
115
|
+
async function fetchEvidence(
|
|
116
|
+
locale: string,
|
|
117
|
+
): Promise<ConsentSignUpResponse | null> {
|
|
118
|
+
// Bounded, so a slow API never holds a sign-up or an OAuth callback: a
|
|
119
|
+
// timeout is a failed read (sign-up fails closed; the interstitial asks an
|
|
120
|
+
// OAuth user later).
|
|
121
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
122
|
+
const timedOut = new Promise<null>((resolve) => {
|
|
123
|
+
timer = setTimeout(() => resolve(null), READ_TIMEOUT_MS);
|
|
124
|
+
});
|
|
125
|
+
try {
|
|
126
|
+
const res = await Promise.race([
|
|
127
|
+
consent.signUpEvidence({ locale }),
|
|
128
|
+
timedOut,
|
|
129
|
+
]);
|
|
130
|
+
if (res === null || !res.ok) return null;
|
|
131
|
+
const parsed = consentSignUpResponseSchema.safeParse(res.body);
|
|
132
|
+
if (!parsed.success) return null;
|
|
133
|
+
cache.set(locale, { evidence: parsed.data, fetchedAt: clock() });
|
|
134
|
+
return parsed.data;
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
} finally {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Concurrent misses for one locale share one read. */
|
|
143
|
+
function refresh(locale: string): Promise<ConsentSignUpResponse | null> {
|
|
144
|
+
const pending = refreshing.get(locale);
|
|
145
|
+
if (pending) return pending;
|
|
146
|
+
const read = fetchEvidence(locale).finally(() => refreshing.delete(locale));
|
|
147
|
+
refreshing.set(locale, read);
|
|
148
|
+
return read;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The cached entry while it may still be served (≤ 10 min old), no network. */
|
|
152
|
+
function cachedEvidence(locale: string): ConsentSignUpResponse | null {
|
|
153
|
+
const entry = cache.get(locale);
|
|
154
|
+
return entry && clock() - entry.fetchedAt <= STALE_MS ? entry.evidence : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Fresh evidence, refreshing when older than 60 s; stale on a failed refresh. */
|
|
158
|
+
async function readEvidence(
|
|
159
|
+
locale: string,
|
|
160
|
+
): Promise<ConsentSignUpResponse | null> {
|
|
161
|
+
const entry = cache.get(locale);
|
|
162
|
+
if (entry && clock() - entry.fetchedAt < FRESH_MS) return entry.evidence;
|
|
163
|
+
return (await refresh(locale)) ?? cachedEvidence(locale);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Whether `raw` names every purpose the evidence asks for. */
|
|
167
|
+
function coversEvidence(
|
|
168
|
+
raw: unknown,
|
|
169
|
+
evidence: ConsentSignUpResponse,
|
|
170
|
+
): boolean {
|
|
171
|
+
const parsed = signUpAgreementsSchema.safeParse(raw);
|
|
172
|
+
if (!parsed.success) return false;
|
|
173
|
+
return evidence.agreements.every((agreement) =>
|
|
174
|
+
parsed.data.includes(agreement.purposeId),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function apiError(
|
|
179
|
+
status: "BAD_REQUEST" | "SERVICE_UNAVAILABLE",
|
|
180
|
+
code: "AGREEMENTS_REQUIRED" | "AGREEMENTS_UNAVAILABLE",
|
|
181
|
+
message: string,
|
|
182
|
+
): Promise<Error> {
|
|
183
|
+
const { APIError } = await import("better-auth/api");
|
|
184
|
+
return new APIError(status, { code, message });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const agreementsRequired = (): Promise<Error> =>
|
|
188
|
+
apiError(
|
|
189
|
+
"BAD_REQUEST",
|
|
190
|
+
"AGREEMENTS_REQUIRED",
|
|
191
|
+
"Accept the agreements to create an account.",
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const agreementsUnavailable = (): Promise<Error> =>
|
|
195
|
+
apiError(
|
|
196
|
+
"SERVICE_UNAVAILABLE",
|
|
197
|
+
"AGREEMENTS_UNAVAILABLE",
|
|
198
|
+
"The sign-up agreements could not be read. Try again in a moment.",
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
/** A request body, as the hooks see it: never trusted, read by key. */
|
|
202
|
+
function bodyField(body: unknown, key: string): unknown {
|
|
203
|
+
return typeof body === "object" && body !== null
|
|
204
|
+
? Reflect.get(body, key)
|
|
205
|
+
: undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── D3 — the plugin ─────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Refuses `POST /sign-up/email` without the agreements (D3), and warms the
|
|
212
|
+
* evidence cache on the OAuth callback so the user hook below finds it
|
|
213
|
+
* without a network call inside the transaction (SEC-8765-I).
|
|
214
|
+
*/
|
|
215
|
+
export function signUpAgreementsPlugin(): BetterAuthPlugin {
|
|
216
|
+
return {
|
|
217
|
+
id: "sign-up-agreements",
|
|
218
|
+
hooks: {
|
|
219
|
+
before: [
|
|
220
|
+
{
|
|
221
|
+
matcher: (context) => context.path === SIGN_UP_PATH,
|
|
222
|
+
handler: async (context) => {
|
|
223
|
+
// Closed sign-up refuses in the handler itself (`disableSignUp`).
|
|
224
|
+
if (!isSignUpOpen()) return;
|
|
225
|
+
const body: unknown = context.body;
|
|
226
|
+
const evidence = await readEvidence(
|
|
227
|
+
normaliseLocale(bodyField(body, "locale")),
|
|
228
|
+
);
|
|
229
|
+
if (!evidence) throw await agreementsUnavailable();
|
|
230
|
+
if (evidence.agreements.length === 0) return;
|
|
231
|
+
if (
|
|
232
|
+
!coversEvidence(
|
|
233
|
+
bodyField(body, SIGN_UP_AGREEMENTS_FIELD),
|
|
234
|
+
evidence,
|
|
235
|
+
)
|
|
236
|
+
) {
|
|
237
|
+
throw await agreementsRequired();
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
matcher: (context) =>
|
|
243
|
+
context.path?.startsWith(CALLBACK_PREFIX) ?? false,
|
|
244
|
+
// Warms only, and only while sign-up is open. The state — and so
|
|
245
|
+
// the locale the card sent — is parsed inside the handler, so every
|
|
246
|
+
// enabled locale is warmed; each is read at most once a minute and
|
|
247
|
+
// each read is bounded. Never refuses.
|
|
248
|
+
handler: async () => {
|
|
249
|
+
if (!isSignUpOpen()) return;
|
|
250
|
+
await Promise.all(
|
|
251
|
+
enabledLocales().map((locale) => readEvidence(locale)),
|
|
252
|
+
);
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── D4 — the transaction capture ────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
const capturedTx = new AsyncLocalStorage<unknown>();
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Wraps the client the Prisma adapter holds. Its `$transaction(fn, options)`
|
|
266
|
+
* calls the client's own `$transaction` and runs `fn(tx)` inside an
|
|
267
|
+
* `AsyncLocalStorage` holding `tx`; every other member is the client's own.
|
|
268
|
+
* The capture never changes what a transaction does: options pass through
|
|
269
|
+
* unchanged, and the array form is delegated as is.
|
|
270
|
+
*
|
|
271
|
+
* A client with no `$transaction` member (a test double) gets `fn(client)` —
|
|
272
|
+
* what the adapter did for it before `transaction: true` — and no `tx` is
|
|
273
|
+
* captured, so a sign-up with agreements fails closed on it.
|
|
274
|
+
*/
|
|
275
|
+
export function withAuthTransactionCapture<Client extends object>(
|
|
276
|
+
client: Client,
|
|
277
|
+
): Client {
|
|
278
|
+
const $transaction = (
|
|
279
|
+
fn: unknown,
|
|
280
|
+
...options: unknown[]
|
|
281
|
+
): Promise<unknown> => {
|
|
282
|
+
const own: unknown = Reflect.get(client, "$transaction");
|
|
283
|
+
if (typeof fn !== "function") {
|
|
284
|
+
return Promise.resolve(
|
|
285
|
+
typeof own === "function"
|
|
286
|
+
? Reflect.apply(own, client, [fn, ...options])
|
|
287
|
+
: undefined,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (typeof own !== "function") {
|
|
291
|
+
return Promise.resolve().then(() =>
|
|
292
|
+
Reflect.apply(fn, undefined, [client]),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
const run = (tx: unknown): unknown =>
|
|
296
|
+
capturedTx.run(tx, () => Reflect.apply(fn, undefined, [tx]));
|
|
297
|
+
return Promise.resolve(Reflect.apply(own, client, [run, ...options]));
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
return new Proxy(client, {
|
|
301
|
+
get: (target, member) =>
|
|
302
|
+
member === "$transaction" ? $transaction : Reflect.get(target, member),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── D4 — the database hooks ─────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/** The one delegate method the account hook calls on the transaction client. */
|
|
309
|
+
interface ConsentGrantWriter {
|
|
310
|
+
consentGrant: {
|
|
311
|
+
createMany(args: { data: ConsentGrantRow[] }): Promise<unknown>;
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
interface ConsentGrantRow {
|
|
316
|
+
siteId: string;
|
|
317
|
+
workspaceId: string;
|
|
318
|
+
subjectType: "user";
|
|
319
|
+
subjectId: string;
|
|
320
|
+
personId: null;
|
|
321
|
+
purposeId: string;
|
|
322
|
+
granted: true;
|
|
323
|
+
source: "sign_up";
|
|
324
|
+
sourceRef: null;
|
|
325
|
+
policyVersion: number;
|
|
326
|
+
locale: string;
|
|
327
|
+
statement: string;
|
|
328
|
+
statementHash: string;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function isConsentGrantWriter(tx: unknown): tx is ConsentGrantWriter {
|
|
332
|
+
if (typeof tx !== "object" || tx === null) return false;
|
|
333
|
+
const delegate: unknown = Reflect.get(tx, "consentGrant");
|
|
334
|
+
return (
|
|
335
|
+
typeof delegate === "object" &&
|
|
336
|
+
delegate !== null &&
|
|
337
|
+
typeof Reflect.get(delegate, "createMany") === "function"
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
type DatabaseHooks = NonNullable<BetterAuthOptions["databaseHooks"]>;
|
|
342
|
+
type HookContext = Parameters<
|
|
343
|
+
NonNullable<
|
|
344
|
+
NonNullable<NonNullable<DatabaseHooks["user"]>["create"]>["before"]
|
|
345
|
+
>
|
|
346
|
+
>[1];
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The evidence the user being created agreed to, or `null` when none is to be
|
|
350
|
+
* recorded. Reads the cache only — the transaction is already open.
|
|
351
|
+
*/
|
|
352
|
+
async function agreedEvidence(
|
|
353
|
+
context: NonNullable<HookContext>,
|
|
354
|
+
): Promise<ConsentSignUpResponse | null> {
|
|
355
|
+
const path = typeof context.path === "string" ? context.path : "";
|
|
356
|
+
|
|
357
|
+
if (path === SIGN_UP_PATH) {
|
|
358
|
+
if (!isSignUpOpen()) return null;
|
|
359
|
+
const evidence = cachedEvidence(
|
|
360
|
+
normaliseLocale(bodyField(context.body, "locale")),
|
|
361
|
+
);
|
|
362
|
+
// The entry aged out between the plugin and here: fail closed.
|
|
363
|
+
if (!evidence) throw await agreementsUnavailable();
|
|
364
|
+
if (evidence.agreements.length === 0) return null;
|
|
365
|
+
// The plugin checked the body against the entry it read; a refresh since
|
|
366
|
+
// may have changed it, so the evidence recorded is checked again here.
|
|
367
|
+
if (
|
|
368
|
+
!coversEvidence(
|
|
369
|
+
bodyField(context.body, SIGN_UP_AGREEMENTS_FIELD),
|
|
370
|
+
evidence,
|
|
371
|
+
)
|
|
372
|
+
) {
|
|
373
|
+
throw await agreementsRequired();
|
|
374
|
+
}
|
|
375
|
+
return evidence;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (path.startsWith(CALLBACK_PREFIX)) {
|
|
379
|
+
const { getOAuthState } = await import("better-auth/api");
|
|
380
|
+
const state: unknown = await getOAuthState().catch(() => null);
|
|
381
|
+
const evidence = cachedEvidence(
|
|
382
|
+
normaliseLocale(bodyField(state, "locale")),
|
|
383
|
+
);
|
|
384
|
+
if (!evidence || evidence.agreements.length === 0) return null;
|
|
385
|
+
return coversEvidence(bodyField(state, SIGN_UP_AGREEMENTS_FIELD), evidence)
|
|
386
|
+
? evidence
|
|
387
|
+
: null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The two database hooks (D4). They share one request's context object —
|
|
395
|
+
* Better Auth hands both the endpoint context it runs the handler in — and
|
|
396
|
+
* that object keys the `WeakMap` between them.
|
|
397
|
+
*/
|
|
398
|
+
export function signUpAgreementHooks(): DatabaseHooks {
|
|
399
|
+
const pending = new WeakMap<object, ConsentSignUpResponse>();
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
user: {
|
|
403
|
+
create: {
|
|
404
|
+
// Changes nothing on the user; only remembers what it agreed to.
|
|
405
|
+
before: async (_user, context) => {
|
|
406
|
+
if (!context) return;
|
|
407
|
+
const evidence = await agreedEvidence(context);
|
|
408
|
+
if (evidence) pending.set(context, evidence);
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
account: {
|
|
413
|
+
create: {
|
|
414
|
+
// Set only in a request creating a user, whose first account this is.
|
|
415
|
+
before: async (account, context) => {
|
|
416
|
+
if (!context) return;
|
|
417
|
+
const evidence = pending.get(context);
|
|
418
|
+
if (!evidence) return;
|
|
419
|
+
pending.delete(context);
|
|
420
|
+
const tx = capturedTx.getStore();
|
|
421
|
+
if (!isConsentGrantWriter(tx)) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
"sign-up agreements: no transaction client captured; refusing to create the user without its grants",
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
await tx.consentGrant.createMany({
|
|
427
|
+
data: evidence.agreements.map((agreement) => ({
|
|
428
|
+
siteId: evidence.workspaceId,
|
|
429
|
+
workspaceId: evidence.workspaceId,
|
|
430
|
+
subjectType: "user",
|
|
431
|
+
subjectId: account.userId,
|
|
432
|
+
personId: null,
|
|
433
|
+
purposeId: agreement.purposeId,
|
|
434
|
+
granted: true,
|
|
435
|
+
source: "sign_up",
|
|
436
|
+
sourceRef: null,
|
|
437
|
+
policyVersion: agreement.policyVersion,
|
|
438
|
+
locale: agreement.locale,
|
|
439
|
+
statement: agreement.statement,
|
|
440
|
+
statementHash: agreement.statementHash,
|
|
441
|
+
})),
|
|
442
|
+
});
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// - innerPages: "bare" → every non-home page renders the bare header and
|
|
17
17
|
// the one-bar footer.
|
|
18
18
|
// - deckPresentation → the deck home presents slide by slide: fitted slides, slide anchors, the mark follows the ground
|
|
19
|
+
// - deckAnchors: "bare" → the presenting deck names its slides #<id> instead of #slide-<id>
|
|
19
20
|
// - colorMode: "light" → the site is pinned to light and shows no theme
|
|
20
21
|
// switch; dark appears only as a deck ground.
|
|
21
22
|
// - chromeStyle: "minimal" → the deck's closing slide is the wordmark,
|
|
@@ -31,6 +32,7 @@ export interface SiteChrome {
|
|
|
31
32
|
home: "default" | "deck";
|
|
32
33
|
innerPages?: "bare";
|
|
33
34
|
deckPresentation?: true;
|
|
35
|
+
deckAnchors?: "bare";
|
|
34
36
|
colorMode?: "light";
|
|
35
37
|
chromeStyle?: "minimal";
|
|
36
38
|
closingFooterPaths?: readonly string[];
|
|
@@ -9,10 +9,14 @@
|
|
|
9
9
|
// `/purchase/success?session_id=cs_…` is the SAME class of bearer capability
|
|
10
10
|
// — the exact defect #5562 fixed for `?g=`, recurring on a second surface.
|
|
11
11
|
//
|
|
12
|
+
// `email` and `login_hint` joined by #8757 (D9, SEC-8757-C): an account link
|
|
13
|
+
// carries the submitter's address as `/sign-in?email=…` and the hub's
|
|
14
|
+
// authorize step as `login_hint=…` — personal data GA and Sentry must not get.
|
|
15
|
+
//
|
|
12
16
|
// Mirrored (duplicated, not shared) in packages/analytics/src/scrub.ts,
|
|
13
17
|
// since apps/* must not import @working-theory/* packages — keep both lists
|
|
14
18
|
// in sync (#5613).
|
|
15
|
-
export const TOKEN_QUERY_PARAMS = ["g", "session_id"];
|
|
19
|
+
export const TOKEN_QUERY_PARAMS = ["g", "session_id", "email", "login_hint"];
|
|
16
20
|
|
|
17
21
|
// Sentry navigation breadcrumbs (`data.to`/`data.from`) are relative
|
|
18
22
|
// (`path?search`, no origin) rather than the absolute URLs GA and
|