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
|
@@ -67,6 +67,11 @@ const mockMemberSendMessage = jest.fn();
|
|
|
67
67
|
const mockMemberBook = jest.fn();
|
|
68
68
|
jest.mock("@working-theory/api-client", () => ({
|
|
69
69
|
__esModule: true,
|
|
70
|
+
// #8765 D7 — no agreement outstanding, so the Inbox loads.
|
|
71
|
+
consent: {
|
|
72
|
+
accountAgreements: () =>
|
|
73
|
+
Promise.resolve({ ok: true, body: { agreements: [], complete: true } }),
|
|
74
|
+
},
|
|
70
75
|
waitlistSignups: { me: jest.fn() },
|
|
71
76
|
subscription: { me: (...args: unknown[]) => mockNotFound(...args) },
|
|
72
77
|
member: {
|
|
@@ -8,6 +8,8 @@ import { tmpdir } from "node:os";
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
|
|
10
10
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
11
|
+
import { consentStatementText } from "@working-theory/ui/blocks";
|
|
12
|
+
import { CONTACT_FORM_TERMS_STATEMENT } from "@working-theory/validation";
|
|
11
13
|
|
|
12
14
|
jest.mock("next-intl/server", () => {
|
|
13
15
|
const msgs: unknown = require("../../../../../messages/en.json");
|
|
@@ -121,9 +123,9 @@ function agreementPurpose(overrides: Partial<{ id: string; state: unknown }> = {
|
|
|
121
123
|
required: true,
|
|
122
124
|
signUp: true,
|
|
123
125
|
description: "Account terms.",
|
|
124
|
-
statement:
|
|
125
|
-
policyHref: "/
|
|
126
|
-
policyVersion:
|
|
126
|
+
statement: CONTACT_FORM_TERMS_STATEMENT,
|
|
127
|
+
policyHref: "/working-agreement",
|
|
128
|
+
policyVersion: 2,
|
|
127
129
|
state: overrides.state === undefined ? null : overrides.state,
|
|
128
130
|
effective: false,
|
|
129
131
|
};
|
|
@@ -274,14 +276,14 @@ describe("AC-4 — account-privacy-agreements (read-only)", () => {
|
|
|
274
276
|
ok({
|
|
275
277
|
purposes: [
|
|
276
278
|
agreementPurpose({
|
|
277
|
-
state: { granted: true, at: "2026-10-01T12:00:00.000Z", source: "sign_up", policyVersion:
|
|
279
|
+
state: { granted: true, at: "2026-10-01T12:00:00.000Z", source: "sign_up", policyVersion: 2 },
|
|
278
280
|
}),
|
|
279
281
|
],
|
|
280
282
|
}),
|
|
281
283
|
);
|
|
282
284
|
await renderPage();
|
|
283
285
|
const row = screen.getByTestId("account-privacy-agreement-account_terms");
|
|
284
|
-
expect(within(row).getByText("Version
|
|
286
|
+
expect(within(row).getByText("Version 2")).toBeInTheDocument();
|
|
285
287
|
expect(within(row).getByText("Accepted Oct 1, 2026")).toBeInTheDocument();
|
|
286
288
|
expect(within(row).queryByRole("switch")).not.toBeInTheDocument();
|
|
287
289
|
expect(within(row).queryByRole("button")).not.toBeInTheDocument();
|
|
@@ -292,6 +294,43 @@ describe("AC-4 — account-privacy-agreements (read-only)", () => {
|
|
|
292
294
|
await renderPage();
|
|
293
295
|
expect(screen.getByTestId("account-privacy-agreement-account_terms-accepted")).toHaveTextContent("None yet");
|
|
294
296
|
});
|
|
297
|
+
|
|
298
|
+
// #8765 D11 / AC-5 (#8761) — the statement renders with its three links.
|
|
299
|
+
it.each(["en", "fr"])(
|
|
300
|
+
"%s: the statement is the Contact sentence with no markdown, linking Terms, Privacy Policy and Working Agreement",
|
|
301
|
+
async (locale) => {
|
|
302
|
+
mockConsents.mockResolvedValue(ok({ purposes: [agreementPurpose()] }));
|
|
303
|
+
await renderPage(locale);
|
|
304
|
+
const statement = screen.getByTestId("account-privacy-agreement-account_terms-statement");
|
|
305
|
+
expect(statement.textContent).toBe(consentStatementText(CONTACT_FORM_TERMS_STATEMENT));
|
|
306
|
+
expect(statement.textContent).not.toContain("[");
|
|
307
|
+
expect(statement.textContent).not.toContain("](");
|
|
308
|
+
const prefix = locale === "en" ? "" : `/${locale}`;
|
|
309
|
+
const links = (["terms", "privacy", "working_agreement"] as const).map((key) => {
|
|
310
|
+
const link = within(statement).getByTestId(`consent-policy-link-${key}`);
|
|
311
|
+
return [link.textContent, link.getAttribute("href")];
|
|
312
|
+
});
|
|
313
|
+
expect(links).toEqual([
|
|
314
|
+
["Terms", `${prefix}/terms`],
|
|
315
|
+
["Privacy Policy", `${prefix}/privacy`],
|
|
316
|
+
["Working Agreement", `${prefix}/working-agreement`],
|
|
317
|
+
]);
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
it("no retired card policy link renders, and the policy link is the summary's, to /privacy", async () => {
|
|
322
|
+
mockConsents.mockResolvedValue(ok({ purposes: [agreementPurpose()] }));
|
|
323
|
+
await renderPage();
|
|
324
|
+
expect(
|
|
325
|
+
document.querySelector('[data-testid^="account-agreements-prompt-"][data-testid$="-policy"]'),
|
|
326
|
+
).toBeNull();
|
|
327
|
+
const policyLinks = screen.getAllByText("Read our privacy policy");
|
|
328
|
+
expect(policyLinks).toHaveLength(1);
|
|
329
|
+
expect(within(screen.getByTestId("account-privacy-summary")).getByTestId("account-privacy-policy")).toHaveAttribute(
|
|
330
|
+
"href",
|
|
331
|
+
"/privacy",
|
|
332
|
+
);
|
|
333
|
+
});
|
|
295
334
|
});
|
|
296
335
|
|
|
297
336
|
describe("AC-4 — a failed consents read", () => {
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
* mismatch, a 5xx, a network error) leaves the cookie valid, so the frame
|
|
22
22
|
* stays on the page and says so, never routing away with a live session.
|
|
23
23
|
* Without an endpoint (a hub or a single site) nothing changes: Better Auth,
|
|
24
|
-
* then home.
|
|
24
|
+
* then home. That logic is the exported `useAccountSignOut`, so the
|
|
25
|
+
* agreements interstitial's Sign out ends the session the same way (#8765 D7).
|
|
25
26
|
*
|
|
26
27
|
* #8231 D1, as amended by #8334 — the rail reads Inbox, a Library group
|
|
27
28
|
* holding Artifacts, and the footer's ?. Settings is not a rail row: it lives
|
|
@@ -120,6 +121,71 @@ export interface AccountFrameProps {
|
|
|
120
121
|
children: ReactNode;
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
export interface AccountSignOutOptions {
|
|
125
|
+
locale: string;
|
|
126
|
+
/** The seam's `SIGN_OUT_ENDPOINT`; absent or `null`: Better Auth signs out (#8465). */
|
|
127
|
+
signOutEndpoint?: string | null | undefined;
|
|
128
|
+
/** Where a hub sign-out lands; defaults to the site home. */
|
|
129
|
+
signInHref?: string | undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface AccountSignOut {
|
|
133
|
+
onSignOut: () => void;
|
|
134
|
+
/** True after a hub sign-out was refused or failed: the session is still live. */
|
|
135
|
+
signOutFailed: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The account area's Log out — the frame's, and the agreements
|
|
140
|
+
* interstitial's Sign out (#8765 D7), so both end the session the same way.
|
|
141
|
+
* With an endpoint (a spoke that signs in through its hub) it POSTs it and
|
|
142
|
+
* navigates to the sign-in page only on a 2xx; a refused or failed sign-out
|
|
143
|
+
* sets `signOutFailed` and navigates nowhere (#8465). Without one, Better
|
|
144
|
+
* Auth signs out, then home.
|
|
145
|
+
*/
|
|
146
|
+
export function useAccountSignOut({
|
|
147
|
+
locale,
|
|
148
|
+
signOutEndpoint,
|
|
149
|
+
signInHref,
|
|
150
|
+
}: AccountSignOutOptions): AccountSignOut {
|
|
151
|
+
const router = useRouter();
|
|
152
|
+
const [signOutFailed, setSignOutFailed] = useState(false);
|
|
153
|
+
const homeHref = localeHref(locale, "/");
|
|
154
|
+
|
|
155
|
+
const onSignOut = (): void => {
|
|
156
|
+
void (async () => {
|
|
157
|
+
setSignOutFailed(false);
|
|
158
|
+
if (signOutEndpoint) {
|
|
159
|
+
// A refused or failed sign-out leaves the session cookie valid, so the
|
|
160
|
+
// page must not pretend otherwise by navigating away (#8465).
|
|
161
|
+
try {
|
|
162
|
+
const res = await fetch(signOutEndpoint, { method: "POST", credentials: "same-origin" });
|
|
163
|
+
if (!res.ok) {
|
|
164
|
+
setSignOutFailed(true);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
setSignOutFailed(true);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
router.push(signInHref ?? homeHref);
|
|
172
|
+
router.refresh();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
await authClient.signOut();
|
|
177
|
+
} catch {
|
|
178
|
+
// The refresh below re-reads the session either way: a sign-out that
|
|
179
|
+
// failed lands the person back on a page that still knows them.
|
|
180
|
+
}
|
|
181
|
+
router.push(homeHref);
|
|
182
|
+
router.refresh();
|
|
183
|
+
})();
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
return { onSignOut, signOutFailed };
|
|
187
|
+
}
|
|
188
|
+
|
|
123
189
|
export function AccountFrame({
|
|
124
190
|
locale,
|
|
125
191
|
user,
|
|
@@ -131,12 +197,11 @@ export function AccountFrame({
|
|
|
131
197
|
signInHref,
|
|
132
198
|
children,
|
|
133
199
|
}: AccountFrameProps): ReactElement {
|
|
134
|
-
const router = useRouter();
|
|
135
200
|
const pathname = usePathname();
|
|
136
201
|
const searchParams = useSearchParams();
|
|
137
202
|
const [dismissed] = useForYouDismissed(ACCOUNT_PERSIST_KEY);
|
|
138
203
|
const [topBarActions, setTopBarActions] = useState<HTMLElement | null>(null);
|
|
139
|
-
const
|
|
204
|
+
const { onSignOut, signOutFailed } = useAccountSignOut({ locale, signOutEndpoint, signInHref });
|
|
140
205
|
|
|
141
206
|
const homeHref = localeHref(locale, "/");
|
|
142
207
|
const inboxHref = localeHref(locale, "/account");
|
|
@@ -162,37 +227,6 @@ export function AccountFrame({
|
|
|
162
227
|
// #8233 D4 — the Inbox route's New lives in the top bar; nowhere else.
|
|
163
228
|
const onInboxRoute = pathname === inboxHref;
|
|
164
229
|
|
|
165
|
-
const onSignOut = (): void => {
|
|
166
|
-
void (async () => {
|
|
167
|
-
setSignOutFailed(false);
|
|
168
|
-
if (signOutEndpoint) {
|
|
169
|
-
// A refused or failed sign-out leaves the session cookie valid, so the
|
|
170
|
-
// page must not pretend otherwise by navigating away (#8465).
|
|
171
|
-
try {
|
|
172
|
-
const res = await fetch(signOutEndpoint, { method: "POST", credentials: "same-origin" });
|
|
173
|
-
if (!res.ok) {
|
|
174
|
-
setSignOutFailed(true);
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
} catch {
|
|
178
|
-
setSignOutFailed(true);
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
router.push(signInHref ?? homeHref);
|
|
182
|
-
router.refresh();
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
try {
|
|
186
|
-
await authClient.signOut();
|
|
187
|
-
} catch {
|
|
188
|
-
// The refresh below re-reads the session either way: a sign-out that
|
|
189
|
-
// failed lands the person back on a page that still knows them.
|
|
190
|
-
}
|
|
191
|
-
router.push(homeHref);
|
|
192
|
-
router.refresh();
|
|
193
|
-
})();
|
|
194
|
-
};
|
|
195
|
-
|
|
196
230
|
const {
|
|
197
231
|
home,
|
|
198
232
|
inbox,
|
|
@@ -33,14 +33,6 @@
|
|
|
33
33
|
* `redirect()` throws `NEXT_REDIRECT` by design — called OUTSIDE any
|
|
34
34
|
* try/catch (mirrors `checkout.actions.ts`'s idiom) so a catch never
|
|
35
35
|
* swallows the navigation this action exists to perform.
|
|
36
|
-
*
|
|
37
|
-
* This file does NOT hold the carried sign-up-agreement consume step
|
|
38
|
-
* (`acceptCarriedAgreementsAction`, #8438 D13) — that lives in this
|
|
39
|
-
* directory's own `agreements-actions.ts` instead, deliberately kept apart
|
|
40
|
-
* from this file's `billing-source.ts` import: that module pulls in
|
|
41
|
-
* `account-data.ts`, `@working-theory/ui` and `lucide-react`, a far heavier
|
|
42
|
-
* footprint than the small, self-contained cookie codec needs. See
|
|
43
|
-
* `agreements-actions.ts`'s own header.
|
|
44
36
|
*/
|
|
45
37
|
|
|
46
38
|
import { redirect } from "next/navigation";
|
|
@@ -1,170 +1,58 @@
|
|
|
1
1
|
"use server";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* account/agreements-actions.ts — the account
|
|
5
|
-
*
|
|
6
|
-
* docs/specs/8438-consent-registry-and-ledger-step-spec.md C6).
|
|
4
|
+
* account/agreements-actions.ts — the account interstitial's one write
|
|
5
|
+
* (#8765 D7, spec docs/specs/8765-sign-up-refuses-without-the-spec.md).
|
|
7
6
|
*
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
7
|
+
* `acceptAccountAgreementsAction` posts the sign-up agreements the
|
|
8
|
+
* interstitial (`agreements-prompt.tsx`) showed to `POST /v1/consent/account`
|
|
9
|
+
* (`consent.acceptAccountAgreements`), with the caller's forwarded cookie —
|
|
10
|
+
* or, on a spoke that signs in through its hub, the hub credential
|
|
11
|
+
* `forwardedCookieInit` returns — so the grant is account-level: subject
|
|
12
|
+
* `user`, in the deployment's site workspace, read by the hub and every
|
|
13
|
+
* spoke alike (C3). The API appends only when no grant exists at the current
|
|
14
|
+
* version, so a double click adds nothing.
|
|
16
15
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* `
|
|
20
|
-
* (`SPOKE_REMOVAL_SEEDS`, `packages/create-ailk/src/topology.ts` D4), and
|
|
21
|
-
* `agreements-prompt.tsx` — this directory's own file, not a sign-in path —
|
|
22
|
-
* must never reach into a dropped seed, or the scaffolder's own
|
|
23
|
-
* dangling-import check (`spokeExclusions`) refuses the spoke build. So the
|
|
24
|
-
* two sides duplicate the small cookie codec (encode there, decode here)
|
|
25
|
-
* rather than share a module, precisely so each side's dependencies stay
|
|
26
|
-
* native to where it lives.
|
|
16
|
+
* It answers `{ ok }` only; the interstitial refreshes on `ok` and the
|
|
17
|
+
* layout's next read decides whether the account loads. A refusal or a throw
|
|
18
|
+
* is `{ ok: false }`, never a throw, so the interstitial shows its error line.
|
|
27
19
|
*
|
|
28
20
|
* Kept as its OWN file, separate from this directory's `actions.ts`
|
|
29
21
|
* (Billing's `openBillingPortal`): that file imports `billing-source.ts`,
|
|
30
|
-
* which pulls in `account-data.ts`, `@working-theory/ui` and
|
|
31
|
-
* `lucide-react` — a far heavier footprint than this small, self-contained
|
|
32
|
-
* cookie codec needs. Keeping it apart means a test (or a bundler) that
|
|
33
|
-
* reaches this file touches nothing from that tree.
|
|
34
|
-
*
|
|
35
|
-
* Cookie value: `<id>@<version>,…|<issuedAtMs>|<sha256(lowercased email)>|
|
|
36
|
-
* <HMAC-SHA256 over the rest>`, keyed by `BETTER_AUTH_SECRET` — the same
|
|
37
|
-
* dev fallback `apps/web/auth.ts`'s own `resolveSecret()` publishes when it
|
|
38
|
-
* is unset. That secret is read directly HERE rather than importing
|
|
39
|
-
* `resolveSecret` from `auth.ts`: that module statically imports the
|
|
40
|
-
* ESM-only `better-auth` package, which every broad page-level test suite
|
|
41
|
-
* that renders the account area would then have to mock. Verified with a
|
|
42
|
-
* constant-time comparison; never trusted without it.
|
|
22
|
+
* which pulls in `account-data.ts`, `@working-theory/ui` and `lucide-react`.
|
|
43
23
|
*/
|
|
44
24
|
|
|
45
|
-
import {
|
|
46
|
-
|
|
47
|
-
import { member } from "@working-theory/api-client";
|
|
48
|
-
import { cookies, headers } from "next/headers";
|
|
25
|
+
import { consent } from "@working-theory/api-client";
|
|
26
|
+
import { getLocale } from "next-intl/server";
|
|
49
27
|
|
|
50
28
|
import { forwardedCookieInit } from "../../../../lib/server-api";
|
|
51
|
-
import { readSession } from "../../../../lib/topology.generated";
|
|
52
|
-
|
|
53
|
-
const SECURE_COOKIE_NAME = "__Host-ailk-signup-agreements";
|
|
54
|
-
const DEV_COOKIE_NAME = "ailk-signup-agreements";
|
|
55
|
-
const MAX_AGE_MS = 60 * 60 * 1000;
|
|
56
|
-
|
|
57
|
-
/** Mirrors `sign-up.actions.ts`'s own copy — see that file's header. */
|
|
58
|
-
const DEV_FALLBACK_SECRET =
|
|
59
|
-
"ailk-dev-fallback-secret-published-in-this-repo-never-use-in-production";
|
|
60
|
-
|
|
61
|
-
/** Mirrors `sign-up.actions.ts`'s own copy exactly — see that file's header. */
|
|
62
|
-
function signUpAgreementsSecret(): string {
|
|
63
|
-
const configured = process.env.BETTER_AUTH_SECRET;
|
|
64
|
-
if (configured) return configured;
|
|
65
|
-
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
|
|
66
|
-
if (process.env.NODE_ENV === "production" && !isBuildPhase) {
|
|
67
|
-
throw new Error(
|
|
68
|
-
"BETTER_AUTH_SECRET is required in production: it verifies the carried sign-up-agreement cookie.",
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
return DEV_FALLBACK_SECRET;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function cookieName(): string {
|
|
75
|
-
return process.env.NODE_ENV === "production" ? SECURE_COOKIE_NAME : DEV_COOKIE_NAME;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function emailHash(email: string): string {
|
|
79
|
-
return createHash("sha256").update(email.trim().toLowerCase()).digest("hex");
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function sign(payload: string): string {
|
|
83
|
-
return createHmac("sha256", signUpAgreementsSecret()).update(payload).digest("hex");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Constant-time string equality — never a `===` on attacker-reachable input. */
|
|
87
|
-
function safeEqual(a: string, b: string): boolean {
|
|
88
|
-
const bufA = Buffer.from(a);
|
|
89
|
-
const bufB = Buffer.from(b);
|
|
90
|
-
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
|
|
91
|
-
}
|
|
92
29
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
30
|
+
/** PII-safe: the status or the error's name, never the ids' owner. */
|
|
31
|
+
function logAcceptFailed(reason: string): void {
|
|
32
|
+
console.error("[account]", {
|
|
33
|
+
event: "account-agreements",
|
|
34
|
+
outcome: "accept_failed",
|
|
35
|
+
reason,
|
|
36
|
+
});
|
|
96
37
|
}
|
|
97
38
|
|
|
98
|
-
interface
|
|
99
|
-
|
|
100
|
-
emailHash: string;
|
|
39
|
+
export interface AcceptAccountAgreementsResult {
|
|
40
|
+
ok: boolean;
|
|
101
41
|
}
|
|
102
42
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const issuedAtMs = Number(issuedAtPart);
|
|
118
|
-
if (!Number.isFinite(issuedAtMs) || Date.now() - issuedAtMs > MAX_AGE_MS) return null;
|
|
119
|
-
|
|
120
|
-
const purposes: CarriedPurpose[] = [];
|
|
121
|
-
for (const entry of purposesPart.split(",")) {
|
|
122
|
-
const [id, versionStr] = entry.split("@");
|
|
123
|
-
const version = Number(versionStr);
|
|
124
|
-
if (!id || !Number.isFinite(version)) return null;
|
|
125
|
-
purposes.push({ id, version });
|
|
43
|
+
export async function acceptAccountAgreementsAction(
|
|
44
|
+
purposeIds: string[],
|
|
45
|
+
): Promise<AcceptAccountAgreementsResult> {
|
|
46
|
+
try {
|
|
47
|
+
const locale = await getLocale();
|
|
48
|
+
const res = await consent.acceptAccountAgreements(
|
|
49
|
+
{ purposeIds, locale },
|
|
50
|
+
await forwardedCookieInit(),
|
|
51
|
+
);
|
|
52
|
+
if (!res.ok) logAcceptFailed(String(res.status));
|
|
53
|
+
return { ok: res.ok };
|
|
54
|
+
} catch (err) {
|
|
55
|
+
logAcceptFailed(err instanceof Error ? err.name : "unknown");
|
|
56
|
+
return { ok: false };
|
|
126
57
|
}
|
|
127
|
-
if (purposes.length === 0) return null;
|
|
128
|
-
|
|
129
|
-
return { purposes, emailHash: emailHashPart };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export interface AcceptCarriedAgreementsResult {
|
|
133
|
-
accepted: boolean;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Consumes the sign-up card's carried-agreement cookie — see the file
|
|
138
|
-
* header for the mint/consume split. Writes every id in `missingPurposeIds`
|
|
139
|
-
* with `via: "sign_up"` only when the cookie is valid, fresh, bound to the
|
|
140
|
-
* signed-in (verified) session's own email, and covers every one of those
|
|
141
|
-
* ids; otherwise writes nothing. The cookie is deleted either way once
|
|
142
|
-
* found, so this never runs twice for the same cookie.
|
|
143
|
-
*/
|
|
144
|
-
export async function acceptCarriedAgreementsAction(
|
|
145
|
-
missingPurposeIds: string[],
|
|
146
|
-
): Promise<AcceptCarriedAgreementsResult> {
|
|
147
|
-
const store = await cookies();
|
|
148
|
-
const name = cookieName();
|
|
149
|
-
const raw = store.get(name)?.value;
|
|
150
|
-
if (!raw) return { accepted: false };
|
|
151
|
-
store.delete(name);
|
|
152
|
-
|
|
153
|
-
const decoded = decodeCarriedAgreements(raw);
|
|
154
|
-
if (!decoded) return { accepted: false };
|
|
155
|
-
|
|
156
|
-
const session = await readSession(await headers());
|
|
157
|
-
if (!session || !session.user.emailVerified) return { accepted: false };
|
|
158
|
-
if (decoded.emailHash !== emailHash(session.user.email)) return { accepted: false };
|
|
159
|
-
|
|
160
|
-
const carriedIds = new Set(decoded.purposes.map((p) => p.id));
|
|
161
|
-
if (!missingPurposeIds.every((id) => carriedIds.has(id))) return { accepted: false };
|
|
162
|
-
|
|
163
|
-
const init = await forwardedCookieInit();
|
|
164
|
-
const results = await Promise.all(
|
|
165
|
-
missingPurposeIds.map((purposeId) =>
|
|
166
|
-
member.setConsent({ purposeId, granted: true, via: "sign_up" }, init),
|
|
167
|
-
),
|
|
168
|
-
);
|
|
169
|
-
return { accepted: results.every((r) => r.ok) };
|
|
170
58
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account area's sign-up agreement gate (#8765 D7, spec
|
|
3
|
+
* docs/specs/8765-sign-up-refuses-without-the-spec.md).
|
|
4
|
+
*
|
|
5
|
+
* `outstandingAgreements(ctx)` is the one read the layout and the Inbox
|
|
6
|
+
* page share: `GET /v1/consent/account` for the caller, `cache()`d per
|
|
7
|
+
* request (keyed on the context `accountSession`, itself cached, hands
|
|
8
|
+
* both). The layout shows the interstitial in place of the account while
|
|
9
|
+
* it answers anything; the Inbox page awaits it before any loader and
|
|
10
|
+
* renders nothing meanwhile, because the App Router renders a page beside
|
|
11
|
+
* its layout even when the layout never renders it — and the Inbox's
|
|
12
|
+
* `member.messages` read stamps `readAt`.
|
|
13
|
+
*
|
|
14
|
+
* `[]` when none is outstanding (the read answered `complete: true`, or an
|
|
15
|
+
* older hub answered 404). It fails closed (SEC-8765-C): any other answer —
|
|
16
|
+
* a thrown read, a 401, 403, 429 or 5xx — is the registry's own agreements
|
|
17
|
+
* from the same constant the API reads (#8438 C1), logged `read_failed`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { consent } from "@working-theory/api-client";
|
|
21
|
+
import type { AccountContext } from "@working-theory/templates/account";
|
|
22
|
+
import {
|
|
23
|
+
resolveConsentStatement,
|
|
24
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
25
|
+
signUpAgreements,
|
|
26
|
+
} from "@working-theory/validation";
|
|
27
|
+
import { cache } from "react";
|
|
28
|
+
|
|
29
|
+
import type { AgreementsInterstitialAgreement } from "./agreements-prompt";
|
|
30
|
+
|
|
31
|
+
export const outstandingAgreements = cache(
|
|
32
|
+
async (ctx: AccountContext): Promise<AgreementsInterstitialAgreement[]> => {
|
|
33
|
+
try {
|
|
34
|
+
const res = await consent.accountAgreements(ctx.init, {
|
|
35
|
+
locale: ctx.locale,
|
|
36
|
+
});
|
|
37
|
+
if (res.ok) {
|
|
38
|
+
if (res.body.complete) return [];
|
|
39
|
+
return res.body.agreements
|
|
40
|
+
.filter((agreement) => !agreement.current)
|
|
41
|
+
.map(({ id, statement, acceptedVersion }) => ({
|
|
42
|
+
id,
|
|
43
|
+
statement,
|
|
44
|
+
acceptedVersion,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
if (res.status === 404) return [];
|
|
48
|
+
} catch {
|
|
49
|
+
// Falls through to the registry's own agreements below.
|
|
50
|
+
}
|
|
51
|
+
console.error("[account]", {
|
|
52
|
+
event: "account-agreements",
|
|
53
|
+
outcome: "read_failed",
|
|
54
|
+
});
|
|
55
|
+
return signUpAgreements(SITE_CONSENT_CONFIG_DEFAULTS).map((purpose) => ({
|
|
56
|
+
id: purpose.id,
|
|
57
|
+
statement: resolveConsentStatement(purpose, ctx.locale).statement,
|
|
58
|
+
acceptedVersion: null,
|
|
59
|
+
}));
|
|
60
|
+
},
|
|
61
|
+
);
|