create-ailk 0.19.1 → 0.20.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 +64 -7
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/module-architecture.js +55 -2
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +32 -2
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.js +67 -2
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/_gitignore +162 -0
- package/templates/apps/api/CLAUDE.md +14 -10
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
- package/templates/apps/api/src/middleware/auth.ts +8 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
- package/templates/apps/api/src/server.ts +14 -2
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/messages/en.json +67 -4
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +39 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +38 -12
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -19,21 +19,41 @@
|
|
|
19
19
|
* launch lands on `errors.email` (the social row sits inside the password form
|
|
20
20
|
* beside the email field). The SSO body has no fields, so the same message is
|
|
21
21
|
* rendered as an `Alert` above the card for `method="sso"`.
|
|
22
|
+
*
|
|
23
|
+
* #8438 D13 — the site's sign-up agreements. `AuthCard` has no slot to render
|
|
24
|
+
* inside and no external "disabled" hook (packages/ui is not this unit's to
|
|
25
|
+
* change), so the card composes its own `ConsentGroup` below `AuthCard` and
|
|
26
|
+
* GATES every entry point from the outside: the password submit and every
|
|
27
|
+
* provider's click both run through this component's own closures, which do
|
|
28
|
+
* nothing until every required agreement is ticked. A wrapping div's
|
|
29
|
+
* `aria-disabled` + `pointer-events-none` styling carries the same state
|
|
30
|
+
* visually for a mouse/touch pointer in a real browser.
|
|
22
31
|
*/
|
|
23
32
|
|
|
24
33
|
import {
|
|
25
34
|
AuthCard,
|
|
35
|
+
ConsentGroup,
|
|
26
36
|
type AuthCardLabels,
|
|
27
37
|
type AuthSubmitData,
|
|
28
38
|
} from "@working-theory/ui/blocks";
|
|
29
39
|
import { Alert, AlertDescription } from "@working-theory/ui/primitives";
|
|
30
|
-
import type
|
|
40
|
+
import { useState, type MouseEvent, type ReactElement } from "react";
|
|
31
41
|
|
|
32
42
|
import { authClient, useAuthCardFlow } from "../use-auth-card-flow";
|
|
33
43
|
|
|
44
|
+
import { rememberSignUpAgreementsAction } from "./sign-up.actions";
|
|
45
|
+
|
|
34
46
|
/** The OAuth providers this page can launch — a subset of AuthCard's. */
|
|
35
47
|
export type SignUpProvider = "google" | "github";
|
|
36
48
|
|
|
49
|
+
/** One site agreement asked at sign-up (#8438 D1's `signUp` purposes). */
|
|
50
|
+
export interface SignUpAgreement {
|
|
51
|
+
id: string;
|
|
52
|
+
required: boolean;
|
|
53
|
+
/** Resolved to the active locale, link tokens included (D1). */
|
|
54
|
+
statement: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
export interface SignUpCardProps {
|
|
38
58
|
/** Active OAuth providers, exactly as auth-env reports them. */
|
|
39
59
|
providers: SignUpProvider[];
|
|
@@ -49,8 +69,16 @@ export interface SignUpCardProps {
|
|
|
49
69
|
notConfiguredSubhead: string;
|
|
50
70
|
/** Translated error copy. */
|
|
51
71
|
errorLabels: { signUpFailed: string; providerFailed: string };
|
|
72
|
+
/** The site's `signUp` agreements — always at least `account_terms`. */
|
|
73
|
+
agreements: SignUpAgreement[];
|
|
74
|
+
/** Policy key → href, for the statements' inline links (D1/D3). */
|
|
75
|
+
agreementPolicies: Record<string, string>;
|
|
76
|
+
/** The consent fieldset's legend (`auth.signUp.agreementsLegend`). */
|
|
77
|
+
agreementsLegend: string;
|
|
52
78
|
}
|
|
53
79
|
|
|
80
|
+
const AGREEMENTS_FIELD_NAME = "agreements";
|
|
81
|
+
|
|
54
82
|
export function SignUpCard({
|
|
55
83
|
providers,
|
|
56
84
|
method,
|
|
@@ -59,6 +87,9 @@ export function SignUpCard({
|
|
|
59
87
|
labels,
|
|
60
88
|
notConfiguredSubhead,
|
|
61
89
|
errorLabels,
|
|
90
|
+
agreements,
|
|
91
|
+
agreementPolicies,
|
|
92
|
+
agreementsLegend,
|
|
62
93
|
}: SignUpCardProps): ReactElement {
|
|
63
94
|
const { errors, providerError, launchProvider, submitPassword } =
|
|
64
95
|
useAuthCardFlow(next, {
|
|
@@ -66,25 +97,62 @@ export function SignUpCard({
|
|
|
66
97
|
passwordFailed: errorLabels.signUpFailed,
|
|
67
98
|
});
|
|
68
99
|
|
|
100
|
+
const requiredAgreementIds = agreements.filter((a) => a.required).map((a) => a.id);
|
|
101
|
+
const [ticked, setTicked] = useState<ReadonlySet<string>>(new Set());
|
|
102
|
+
const allAgreed = requiredAgreementIds.every((id) => ticked.has(id));
|
|
103
|
+
|
|
104
|
+
// `ConsentGroup`'s checkboxes are uncontrolled, by design (#5275), and
|
|
105
|
+
// always start unticked (no `defaults` are ever passed here) — so a click
|
|
106
|
+
// on one is always a plain toggle of the LOCAL record below; this never
|
|
107
|
+
// reads the Radix root's own post-click DOM state, which a same-tick
|
|
108
|
+
// ancestor listener could still see as stale.
|
|
109
|
+
function handleAgreementsClick(event: MouseEvent<HTMLDivElement>): void {
|
|
110
|
+
const target = (event.target as HTMLElement).closest<HTMLElement>('[role="checkbox"]');
|
|
111
|
+
if (!target?.id) return;
|
|
112
|
+
const purposeId = target.id.slice(`${AGREEMENTS_FIELD_NAME}.`.length);
|
|
113
|
+
setTicked((previous) => {
|
|
114
|
+
const updated = new Set(previous);
|
|
115
|
+
if (updated.has(purposeId)) updated.delete(purposeId);
|
|
116
|
+
else updated.add(purposeId);
|
|
117
|
+
return updated;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
69
121
|
const notConfigured = method === "sso" && providers.length === 0;
|
|
70
122
|
|
|
71
123
|
const providerButtons = providers.map((provider) => ({
|
|
72
124
|
provider,
|
|
73
|
-
|
|
125
|
+
// Gated: a provider launch that would create the account is refused
|
|
126
|
+
// until every required agreement is ticked, exactly as the password
|
|
127
|
+
// submit below. No remember-cookie here — Better Auth's own OAuth
|
|
128
|
+
// callback carries no form data this component could read an email
|
|
129
|
+
// from, so the account home's explicit "Otherwise" card asks instead.
|
|
130
|
+
onClick: () => {
|
|
131
|
+
if (!allAgreed) return;
|
|
132
|
+
void launchProvider(provider);
|
|
133
|
+
},
|
|
74
134
|
}));
|
|
75
135
|
|
|
76
136
|
const handleSubmit = (data: AuthSubmitData): void => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
137
|
+
if (!allAgreed) return;
|
|
138
|
+
void (async () => {
|
|
139
|
+
try {
|
|
140
|
+
await rememberSignUpAgreementsAction(data.email, requiredAgreementIds);
|
|
141
|
+
} catch {
|
|
142
|
+
// Convenience only — see sign-up.actions.ts's file header.
|
|
143
|
+
}
|
|
144
|
+
// Better Auth requires a `name` on email sign-up; this card collects
|
|
145
|
+
// none, so the address doubles as the display name until the person
|
|
146
|
+
// sets one.
|
|
147
|
+
await submitPassword(
|
|
148
|
+
authClient.signUp.email({
|
|
149
|
+
email: data.email,
|
|
150
|
+
password: data.password ?? "",
|
|
151
|
+
name: data.email,
|
|
152
|
+
callbackURL: next,
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
})();
|
|
88
156
|
};
|
|
89
157
|
|
|
90
158
|
return (
|
|
@@ -94,16 +162,31 @@ export function SignUpCard({
|
|
|
94
162
|
<AlertDescription>{providerError}</AlertDescription>
|
|
95
163
|
</Alert>
|
|
96
164
|
) : null}
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
165
|
+
<div
|
|
166
|
+
aria-disabled={!allAgreed}
|
|
167
|
+
className={allAgreed ? undefined : "pointer-events-none opacity-60"}
|
|
168
|
+
data-testid="sign-up-card-gate"
|
|
169
|
+
>
|
|
170
|
+
<AuthCard
|
|
171
|
+
intent="sign-up"
|
|
172
|
+
method={method}
|
|
173
|
+
signInHref={signInHref}
|
|
174
|
+
{...(notConfigured ? { subhead: notConfiguredSubhead } : {})}
|
|
175
|
+
providers={providerButtons}
|
|
176
|
+
onSubmit={handleSubmit}
|
|
177
|
+
errors={errors}
|
|
178
|
+
labels={labels}
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
{/* eslint-disable-next-line -- delegated-click capture over ConsentGroup's own real `role="checkbox"` descendants (Radix), which are themselves already keyboard-operable and dispatch a native click on Enter/Space; this wrapper adds no interactive semantics of its own and is never itself focused. (Bare directive, not named rules: `jsx-a11y/*` is registered in this monorepo's own lint but not in a scaffolded site's trimmed eslint config — see agreements-prompt.tsx's identical note on `react-hooks/exhaustive-deps`.) */}
|
|
182
|
+
<div onClick={handleAgreementsClick} data-testid="sign-up-agreements">
|
|
183
|
+
<ConsentGroup
|
|
184
|
+
name={AGREEMENTS_FIELD_NAME}
|
|
185
|
+
legend={agreementsLegend}
|
|
186
|
+
purposes={agreements}
|
|
187
|
+
policies={agreementPolicies}
|
|
188
|
+
/>
|
|
189
|
+
</div>
|
|
107
190
|
</div>
|
|
108
191
|
);
|
|
109
192
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* sign-up.actions.ts — mints the sign-up card's carried-agreement cookie
|
|
5
|
+
* (#8438 D13, spec
|
|
6
|
+
* docs/specs/8438-consent-registry-and-ledger-step-spec.md C6).
|
|
7
|
+
*
|
|
8
|
+
* `apps/web` never touches the database (the ledger writer is `apps/api`),
|
|
9
|
+
* and Better Auth creates the user from THIS app before any Person exists to
|
|
10
|
+
* record a grant against. So the card remembers which agreements the person
|
|
11
|
+
* ticked, in a short-lived, signed, email-bound cookie — the account home's
|
|
12
|
+
* first signed-in visit (`(authed)/account/actions.ts`'s
|
|
13
|
+
* `acceptCarriedAgreementsAction`) consumes it, one write per agreement,
|
|
14
|
+
* then deletes it either way (single-use).
|
|
15
|
+
*
|
|
16
|
+
* The CONSUME side lives in the account area, not here, deliberately:
|
|
17
|
+
* `(auth)/**` is one of the sign-in seeds a sign-in-less spoke drops
|
|
18
|
+
* (`SPOKE_REMOVAL_SEEDS`, `packages/create-ailk/src/topology.ts` D4), so a
|
|
19
|
+
* file OUTSIDE `(auth)` (the account home's `agreements-prompt.tsx`) must
|
|
20
|
+
* never import FROM here — doing so once failed the scaffolder's own
|
|
21
|
+
* dangling-import check (`spokeExclusions`) for exactly this reason. This
|
|
22
|
+
* file is reached only from `sign-up-card.tsx`, its own directory sibling,
|
|
23
|
+
* so it carries no such obligation. The two files duplicate the small
|
|
24
|
+
* cookie codec (encode here, decode there) rather than share a module,
|
|
25
|
+
* precisely so each side's dependencies stay native to where it lives.
|
|
26
|
+
*
|
|
27
|
+
* Cookie value: `<id>@<version>,…|<issuedAtMs>|<sha256(lowercased email)>|
|
|
28
|
+
* <HMAC-SHA256 over the rest>`, keyed by `BETTER_AUTH_SECRET` — the same
|
|
29
|
+
* dev fallback `apps/web/auth.ts`'s own `resolveSecret()` publishes when it
|
|
30
|
+
* is unset; production refuses to mint without one. That secret is read
|
|
31
|
+
* directly HERE rather than importing `resolveSecret` from `auth.ts`: that
|
|
32
|
+
* module statically imports the `better-auth` ESM package, which every
|
|
33
|
+
* broad page-level test suite that renders `/sign-up` would then have to
|
|
34
|
+
* add to its `../../../../auth` mock. `__Host-` prefixed, httpOnly,
|
|
35
|
+
* `Secure`, one hour, `SameSite=Lax` — the `__Host-` prefix needs `Secure`,
|
|
36
|
+
* so development over plain `http://localhost` uses the unprefixed name
|
|
37
|
+
* instead (SEC-8438-5).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { createHash, createHmac } from "node:crypto";
|
|
41
|
+
|
|
42
|
+
import { consentPurposeById, SITE_CONSENT_CONFIG_DEFAULTS } from "@working-theory/validation";
|
|
43
|
+
import { cookies } from "next/headers";
|
|
44
|
+
|
|
45
|
+
const SECURE_COOKIE_NAME = "__Host-ailk-signup-agreements";
|
|
46
|
+
const DEV_COOKIE_NAME = "ailk-signup-agreements";
|
|
47
|
+
const MAX_AGE_SECONDS = 60 * 60;
|
|
48
|
+
|
|
49
|
+
/** The string `auth.ts`'s own `resolveSecret()` publishes — see file header. */
|
|
50
|
+
const DEV_FALLBACK_SECRET =
|
|
51
|
+
"ailk-dev-fallback-secret-published-in-this-repo-never-use-in-production";
|
|
52
|
+
|
|
53
|
+
/** Mirrors `auth.ts`'s `resolveSecret()` exactly, without importing it. */
|
|
54
|
+
function signUpAgreementsSecret(): string {
|
|
55
|
+
const configured = process.env.BETTER_AUTH_SECRET;
|
|
56
|
+
if (configured) return configured;
|
|
57
|
+
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
|
|
58
|
+
if (process.env.NODE_ENV === "production" && !isBuildPhase) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"BETTER_AUTH_SECRET is required in production: it signs the carried sign-up-agreement cookie.",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return DEV_FALLBACK_SECRET;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function cookieName(): string {
|
|
67
|
+
return process.env.NODE_ENV === "production" ? SECURE_COOKIE_NAME : DEV_COOKIE_NAME;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function emailHash(email: string): string {
|
|
71
|
+
return createHash("sha256").update(email.trim().toLowerCase()).digest("hex");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sign(payload: string): string {
|
|
75
|
+
return createHmac("sha256", signUpAgreementsSecret()).update(payload).digest("hex");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface CarriedPurpose {
|
|
79
|
+
id: string;
|
|
80
|
+
version: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function encodeCarriedAgreements(
|
|
84
|
+
purposes: CarriedPurpose[],
|
|
85
|
+
email: string,
|
|
86
|
+
issuedAtMs: number,
|
|
87
|
+
): string {
|
|
88
|
+
const purposesPart = purposes.map((p) => `${p.id}@${p.version}`).join(",");
|
|
89
|
+
const payload = `${purposesPart}|${issuedAtMs}|${emailHash(email)}`;
|
|
90
|
+
return `${payload}|${sign(payload)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Mints the carried-agreement cookie from the ticked `signUp` agreements,
|
|
95
|
+
* read back from the registry for their CURRENT version — never trusting a
|
|
96
|
+
* version the client might assert. Purposes the registry no longer marks
|
|
97
|
+
* `signUp` are dropped. Never throws: a failed mint just means the account
|
|
98
|
+
* home's first visit asks explicitly instead (D13's "Otherwise" card).
|
|
99
|
+
*/
|
|
100
|
+
export async function rememberSignUpAgreementsAction(
|
|
101
|
+
email: string,
|
|
102
|
+
purposeIds: string[],
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
try {
|
|
105
|
+
const config = SITE_CONSENT_CONFIG_DEFAULTS;
|
|
106
|
+
const purposes: CarriedPurpose[] = [];
|
|
107
|
+
for (const id of purposeIds) {
|
|
108
|
+
const purpose = consentPurposeById(config, id);
|
|
109
|
+
if (purpose?.signUp) purposes.push({ id: purpose.id, version: purpose.policyVersion });
|
|
110
|
+
}
|
|
111
|
+
if (purposes.length === 0) return;
|
|
112
|
+
|
|
113
|
+
const value = encodeCarriedAgreements(purposes, email, Date.now());
|
|
114
|
+
const cookieStore = await cookies();
|
|
115
|
+
cookieStore.set(cookieName(), value, {
|
|
116
|
+
httpOnly: true,
|
|
117
|
+
secure: process.env.NODE_ENV === "production",
|
|
118
|
+
sameSite: "lax",
|
|
119
|
+
path: "/",
|
|
120
|
+
maxAge: MAX_AGE_SECONDS,
|
|
121
|
+
});
|
|
122
|
+
} catch {
|
|
123
|
+
// Convenience only — see file header.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for AgreementsPrompt (#8438 D13) and the carried-agreement cookie
|
|
3
|
+
* `acceptCarriedAgreementsAction` consumes — exercised here because this
|
|
4
|
+
* prompt is its one caller.
|
|
5
|
+
*/
|
|
6
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
7
|
+
|
|
8
|
+
const mockRefresh = jest.fn();
|
|
9
|
+
jest.mock("next/navigation", () => ({
|
|
10
|
+
__esModule: true,
|
|
11
|
+
useRouter: () => ({ refresh: mockRefresh }),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const mockAcceptCarried = jest.fn();
|
|
15
|
+
// eslint-disable-next-line no-restricted-syntax -- test-seam mock: the Server Action module is a "use server" boundary; the component only passes it through.
|
|
16
|
+
jest.mock("../agreements-actions", () => ({
|
|
17
|
+
__esModule: true,
|
|
18
|
+
acceptCarriedAgreementsAction: (...args: unknown[]) => mockAcceptCarried(...args),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const mockSetConsent = jest.fn();
|
|
22
|
+
// eslint-disable-next-line no-restricted-syntax -- test-seam mock: the Server Action module is a "use server" boundary; the component only passes it through.
|
|
23
|
+
jest.mock("../privacy/actions", () => ({
|
|
24
|
+
__esModule: true,
|
|
25
|
+
setConsentAction: (...args: unknown[]) => mockSetConsent(...args),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import { AgreementsPrompt, type MissingAgreement } from "../agreements-prompt";
|
|
29
|
+
|
|
30
|
+
const LABELS = {
|
|
31
|
+
titleNew: "Please accept our terms",
|
|
32
|
+
titleUpdated: "We've updated our terms",
|
|
33
|
+
accept: "Accept",
|
|
34
|
+
error: "We couldn't save that. Try again.",
|
|
35
|
+
policyLink: "Read our privacy policy",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const AGREEMENT: MissingAgreement = {
|
|
39
|
+
id: "account_terms",
|
|
40
|
+
statement: "You agree to our Terms and Privacy Policy.",
|
|
41
|
+
policyHref: "/terms",
|
|
42
|
+
version: 1,
|
|
43
|
+
hadPriorGrant: false,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
mockRefresh.mockReset();
|
|
48
|
+
mockAcceptCarried.mockReset();
|
|
49
|
+
mockSetConsent.mockReset();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("AgreementsPrompt — with nothing missing", () => {
|
|
53
|
+
it("renders nothing and calls nothing", () => {
|
|
54
|
+
render(<AgreementsPrompt missing={[]} labels={LABELS} />);
|
|
55
|
+
expect(screen.queryByTestId("account-agreements-prompt")).not.toBeInTheDocument();
|
|
56
|
+
expect(mockAcceptCarried).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("AgreementsPrompt — the carried-cookie fast path (D13)", () => {
|
|
61
|
+
it("tries it once on mount, with every missing id", async () => {
|
|
62
|
+
mockAcceptCarried.mockResolvedValue({ accepted: false });
|
|
63
|
+
render(<AgreementsPrompt missing={[AGREEMENT]} labels={LABELS} />);
|
|
64
|
+
await waitFor(() => expect(mockAcceptCarried).toHaveBeenCalledWith(["account_terms"]));
|
|
65
|
+
expect(mockAcceptCarried).toHaveBeenCalledTimes(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("on accept, hides and refreshes — the card never shows", async () => {
|
|
69
|
+
mockAcceptCarried.mockResolvedValue({ accepted: true });
|
|
70
|
+
render(<AgreementsPrompt missing={[AGREEMENT]} labels={LABELS} />);
|
|
71
|
+
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
|
|
72
|
+
expect(screen.queryByTestId("account-agreements-prompt")).not.toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("on a failed check, shows the card with the statement and policy link", async () => {
|
|
76
|
+
mockAcceptCarried.mockResolvedValue({ accepted: false });
|
|
77
|
+
render(<AgreementsPrompt missing={[AGREEMENT]} labels={LABELS} />);
|
|
78
|
+
expect(await screen.findByTestId("account-agreements-prompt")).toBeInTheDocument();
|
|
79
|
+
expect(screen.getByText("Please accept our terms")).toBeInTheDocument();
|
|
80
|
+
expect(screen.getByText("You agree to our Terms and Privacy Policy.")).toBeInTheDocument();
|
|
81
|
+
expect(screen.getByTestId("account-agreements-prompt-account_terms-policy")).toHaveAttribute(
|
|
82
|
+
"href",
|
|
83
|
+
"/terms",
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("shows the 'updated' title when any missing agreement had a prior grant", async () => {
|
|
88
|
+
mockAcceptCarried.mockResolvedValue({ accepted: false });
|
|
89
|
+
render(<AgreementsPrompt missing={[{ ...AGREEMENT, hadPriorGrant: true }]} labels={LABELS} />);
|
|
90
|
+
expect(await screen.findByText("We've updated our terms")).toBeInTheDocument();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("a rejected check still shows the card, rather than throwing", async () => {
|
|
94
|
+
mockAcceptCarried.mockRejectedValue(new Error("down"));
|
|
95
|
+
render(<AgreementsPrompt missing={[AGREEMENT]} labels={LABELS} />);
|
|
96
|
+
expect(await screen.findByTestId("account-agreements-prompt")).toBeInTheDocument();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("AgreementsPrompt — the explicit Accept button", () => {
|
|
101
|
+
async function showCard(): Promise<void> {
|
|
102
|
+
mockAcceptCarried.mockResolvedValue({ accepted: false });
|
|
103
|
+
render(<AgreementsPrompt missing={[AGREEMENT]} labels={LABELS} />);
|
|
104
|
+
await screen.findByTestId("account-agreements-prompt");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it("posts every missing id through setConsentAction, then refreshes", async () => {
|
|
108
|
+
mockSetConsent.mockResolvedValue({ ok: true, dateLine: "Given Oct 12, 2026" });
|
|
109
|
+
await showCard();
|
|
110
|
+
fireEvent.click(screen.getByTestId("account-agreements-prompt-accept"));
|
|
111
|
+
await waitFor(() => expect(mockSetConsent).toHaveBeenCalledWith("account_terms", true));
|
|
112
|
+
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("a failed answer shows the error and keeps the card", async () => {
|
|
116
|
+
mockSetConsent.mockResolvedValue({ ok: false, message: "nope" });
|
|
117
|
+
await showCard();
|
|
118
|
+
fireEvent.click(screen.getByTestId("account-agreements-prompt-accept"));
|
|
119
|
+
expect(await screen.findByTestId("account-agreements-prompt-error")).toHaveTextContent(
|
|
120
|
+
"We couldn't save that. Try again.",
|
|
121
|
+
);
|
|
122
|
+
expect(screen.getByTestId("account-agreements-prompt")).toBeInTheDocument();
|
|
123
|
+
expect(mockRefresh).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── acceptCarriedAgreementsAction's own cookie checks (D13 C6) ────────────────
|
|
128
|
+
//
|
|
129
|
+
// The real action, loaded past the mock above — exercising the HMAC, the
|
|
130
|
+
// one-hour window and the email-hash binding with real `node:crypto`.
|
|
131
|
+
|
|
132
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426 D5), not a runtime input; the seam IS the boundary between a hub and a spoke build, and there is no env or HTTP surface that selects it.
|
|
133
|
+
jest.mock("../../../../../lib/topology.generated", () => ({
|
|
134
|
+
__esModule: true,
|
|
135
|
+
readSession: jest.fn(),
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
const mockMemberSetConsent = jest.fn();
|
|
139
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
140
|
+
__esModule: true,
|
|
141
|
+
member: { setConsent: (...args: unknown[]) => mockMemberSetConsent(...args) },
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
const mockCookieGet = jest.fn();
|
|
145
|
+
const mockCookieDelete = jest.fn();
|
|
146
|
+
jest.mock("next/headers", () => ({
|
|
147
|
+
__esModule: true,
|
|
148
|
+
cookies: jest.fn().mockResolvedValue({
|
|
149
|
+
get: (...args: unknown[]) => mockCookieGet(...args),
|
|
150
|
+
delete: (...args: unknown[]) => mockCookieDelete(...args),
|
|
151
|
+
toString: () => "",
|
|
152
|
+
}),
|
|
153
|
+
headers: jest.fn().mockResolvedValue(new Headers()),
|
|
154
|
+
}));
|
|
155
|
+
|
|
156
|
+
describe("acceptCarriedAgreementsAction (the real module, D13 C6)", () => {
|
|
157
|
+
const COOKIE_NAME = "ailk-signup-agreements"; // NODE_ENV !== "production" in jest
|
|
158
|
+
const EMAIL = "ada@example.com";
|
|
159
|
+
|
|
160
|
+
async function remember(email: string, purposeIds: string[]): Promise<string | undefined> {
|
|
161
|
+
const { rememberSignUpAgreementsAction } = jest.requireActual(
|
|
162
|
+
"../../../(auth)/sign-up/sign-up.actions",
|
|
163
|
+
) as typeof import("../../../(auth)/sign-up/sign-up.actions");
|
|
164
|
+
await rememberSignUpAgreementsAction(email, purposeIds);
|
|
165
|
+
expect(mockCookieSet).toHaveBeenCalledTimes(1);
|
|
166
|
+
return mockCookieSet.mock.calls[0]?.[1] as string | undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const mockCookieSet = jest.fn();
|
|
170
|
+
|
|
171
|
+
beforeEach(() => {
|
|
172
|
+
mockCookieGet.mockReset();
|
|
173
|
+
mockCookieDelete.mockReset();
|
|
174
|
+
mockCookieSet.mockReset();
|
|
175
|
+
mockMemberSetConsent.mockReset().mockResolvedValue({ ok: true, body: { purpose: {} } });
|
|
176
|
+
(jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockReset();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Minting goes through `cookies().set`, which the outer `cookies()` mock
|
|
180
|
+
// does not define — reassign it per test so `remember()` can capture the
|
|
181
|
+
// value, without disturbing the delete-tracking mock the "real action"
|
|
182
|
+
// suite shares.
|
|
183
|
+
beforeEach(() => {
|
|
184
|
+
const headersModule = jest.requireMock("next/headers") as {
|
|
185
|
+
cookies: jest.Mock;
|
|
186
|
+
};
|
|
187
|
+
headersModule.cookies.mockResolvedValue({
|
|
188
|
+
get: (...args: unknown[]) => mockCookieGet(...args),
|
|
189
|
+
set: (...args: unknown[]) => mockCookieSet(...args),
|
|
190
|
+
delete: (...args: unknown[]) => mockCookieDelete(...args),
|
|
191
|
+
toString: () => "",
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
async function accept(missingPurposeIds: string[]) {
|
|
196
|
+
const { acceptCarriedAgreementsAction } = jest.requireActual(
|
|
197
|
+
"../agreements-actions",
|
|
198
|
+
) as typeof import("../agreements-actions");
|
|
199
|
+
return acceptCarriedAgreementsAction(missingPurposeIds);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
it("a valid, fresh cookie whose email matches writes every missing purpose with via sign_up, then deletes the cookie", async () => {
|
|
203
|
+
const value = await remember(EMAIL, ["account_terms"]);
|
|
204
|
+
mockCookieGet.mockReturnValue({ value });
|
|
205
|
+
(jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockResolvedValue({
|
|
206
|
+
user: { email: EMAIL, emailVerified: true },
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const result = await accept(["account_terms"]);
|
|
210
|
+
expect(result).toEqual({ accepted: true });
|
|
211
|
+
expect(mockMemberSetConsent).toHaveBeenCalledWith(
|
|
212
|
+
{ purposeId: "account_terms", granted: true, via: "sign_up" },
|
|
213
|
+
expect.anything(),
|
|
214
|
+
);
|
|
215
|
+
expect(mockCookieDelete).toHaveBeenCalledWith(COOKIE_NAME);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("a flipped character in the cookie fails the HMAC — posts nothing, deletes it, not accepted", async () => {
|
|
219
|
+
const value = await remember(EMAIL, ["account_terms"]);
|
|
220
|
+
const tampered = `${value?.slice(0, -1)}${value?.slice(-1) === "0" ? "1" : "0"}`;
|
|
221
|
+
mockCookieGet.mockReturnValue({ value: tampered });
|
|
222
|
+
|
|
223
|
+
const result = await accept(["account_terms"]);
|
|
224
|
+
expect(result).toEqual({ accepted: false });
|
|
225
|
+
expect(mockMemberSetConsent).not.toHaveBeenCalled();
|
|
226
|
+
expect(mockCookieDelete).toHaveBeenCalledWith(COOKIE_NAME);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("another email's hash is refused, even with a verified session", async () => {
|
|
230
|
+
const value = await remember("someone-else@example.com", ["account_terms"]);
|
|
231
|
+
mockCookieGet.mockReturnValue({ value });
|
|
232
|
+
(jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockResolvedValue({
|
|
233
|
+
user: { email: EMAIL, emailVerified: true },
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const result = await accept(["account_terms"]);
|
|
237
|
+
expect(result).toEqual({ accepted: false });
|
|
238
|
+
expect(mockMemberSetConsent).not.toHaveBeenCalled();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("an unverified session email is refused even when the hash would match", async () => {
|
|
242
|
+
const value = await remember(EMAIL, ["account_terms"]);
|
|
243
|
+
mockCookieGet.mockReturnValue({ value });
|
|
244
|
+
(jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockResolvedValue({
|
|
245
|
+
user: { email: EMAIL, emailVerified: false },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const result = await accept(["account_terms"]);
|
|
249
|
+
expect(result).toEqual({ accepted: false });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("a cookie that does not cover every missing id is refused", async () => {
|
|
253
|
+
const value = await remember(EMAIL, ["account_terms"]);
|
|
254
|
+
mockCookieGet.mockReturnValue({ value });
|
|
255
|
+
(jest.requireMock("../../../../../lib/topology.generated") as { readSession: jest.Mock }).readSession.mockResolvedValue({
|
|
256
|
+
user: { email: EMAIL, emailVerified: true },
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const result = await accept(["account_terms", "another_agreement"]);
|
|
260
|
+
expect(result).toEqual({ accepted: false });
|
|
261
|
+
expect(mockMemberSetConsent).not.toHaveBeenCalled();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("an issuedAt more than an hour old is refused", async () => {
|
|
265
|
+
jest.useFakeTimers().setSystemTime(new Date("2026-10-12T00:00:00.000Z"));
|
|
266
|
+
const value = await remember(EMAIL, ["account_terms"]);
|
|
267
|
+
jest.setSystemTime(new Date("2026-10-12T01:00:01.000Z"));
|
|
268
|
+
mockCookieGet.mockReturnValue({ value });
|
|
269
|
+
|
|
270
|
+
const result = await accept(["account_terms"]);
|
|
271
|
+
expect(result).toEqual({ accepted: false });
|
|
272
|
+
jest.useRealTimers();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("no cookie present is refused, with no delete call", async () => {
|
|
276
|
+
mockCookieGet.mockReturnValue(undefined);
|
|
277
|
+
const result = await accept(["account_terms"]);
|
|
278
|
+
expect(result).toEqual({ accepted: false });
|
|
279
|
+
expect(mockCookieDelete).not.toHaveBeenCalled();
|
|
280
|
+
});
|
|
281
|
+
});
|