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
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* match, never overwriting a link.
|
|
14
14
|
* - `recordCheckoutPerson` — the webhook's step: the Person of a completed
|
|
15
15
|
* hub checkout, with its live-mode customer id.
|
|
16
|
+
* `resolveCheckoutPerson` is the same step answering that Person too, for
|
|
17
|
+
* the checkout's terms grant (#8765 D14); the webhook calls it, and
|
|
18
|
+
* `recordCheckoutPerson` stays as its outcome-only form for the #852
|
|
19
|
+
* suites that pin that outcome.
|
|
16
20
|
* - `resolveMemberPersonId` — the member reads' Person (#7877 D8): the one
|
|
17
21
|
* linked to the caller, else the one whose email is the caller's
|
|
18
22
|
* verified email, linked on first read by `stampPersonUser`'s rule.
|
|
@@ -768,6 +772,13 @@ export type CheckoutPersonOutcome =
|
|
|
768
772
|
| "person_skipped"
|
|
769
773
|
| "person_failed";
|
|
770
774
|
|
|
775
|
+
/** `recordCheckoutPerson`'s outcome plus the Person it found or created (#8765 D14). */
|
|
776
|
+
export type CheckoutPersonResult = {
|
|
777
|
+
outcome: CheckoutPersonOutcome;
|
|
778
|
+
/** The checkout's Person, or `null` when the step skipped or failed. */
|
|
779
|
+
person: { workspaceId: string; personId: string } | null;
|
|
780
|
+
};
|
|
781
|
+
|
|
771
782
|
/**
|
|
772
783
|
* The hub webhook's Person step for a paid `checkout.session.completed` of
|
|
773
784
|
* its OWN site carrying `metadata.flowCheckoutId`.
|
|
@@ -786,8 +797,23 @@ export async function recordCheckoutPerson(input: {
|
|
|
786
797
|
customer: unknown;
|
|
787
798
|
livemode: boolean;
|
|
788
799
|
}): Promise<CheckoutPersonOutcome> {
|
|
800
|
+
return (await resolveCheckoutPerson(input)).outcome;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* `recordCheckoutPerson`, also answering the Person it found or created —
|
|
805
|
+
* the subject of the checkout's terms grant (#8765 D14), which reads the
|
|
806
|
+
* flow checkout's Person from here and never creates one itself.
|
|
807
|
+
*/
|
|
808
|
+
export async function resolveCheckoutPerson(input: {
|
|
809
|
+
flowCheckoutId: string;
|
|
810
|
+
stripeSessionId: string | null | undefined;
|
|
811
|
+
customer: unknown;
|
|
812
|
+
livemode: boolean;
|
|
813
|
+
}): Promise<CheckoutPersonResult> {
|
|
814
|
+
const skipped = { outcome: "person_skipped", person: null } as const;
|
|
789
815
|
if (typeof input.stripeSessionId !== "string" || input.stripeSessionId === "") {
|
|
790
|
-
return
|
|
816
|
+
return skipped;
|
|
791
817
|
}
|
|
792
818
|
try {
|
|
793
819
|
const checkout = await prisma.flowCheckout.findFirst({
|
|
@@ -798,13 +824,13 @@ export async function recordCheckoutPerson(input: {
|
|
|
798
824
|
},
|
|
799
825
|
select: { leadId: true, workspaceId: true },
|
|
800
826
|
});
|
|
801
|
-
if (!checkout) return
|
|
827
|
+
if (!checkout) return skipped;
|
|
802
828
|
|
|
803
829
|
const lead = await prisma.lead.findFirst({
|
|
804
830
|
where: { id: checkout.leadId, workspaceId: checkout.workspaceId },
|
|
805
831
|
select: { email: true },
|
|
806
832
|
});
|
|
807
|
-
if (!lead) return
|
|
833
|
+
if (!lead) return skipped;
|
|
808
834
|
|
|
809
835
|
const customerId =
|
|
810
836
|
input.livemode === true && typeof input.customer === "string"
|
|
@@ -815,18 +841,24 @@ export async function recordCheckoutPerson(input: {
|
|
|
815
841
|
...(customerId !== undefined && { billingCustomerId: customerId }),
|
|
816
842
|
});
|
|
817
843
|
if (!result.ok) {
|
|
818
|
-
return result.error.kind === "invalid_email"
|
|
844
|
+
return result.error.kind === "invalid_email"
|
|
845
|
+
? skipped
|
|
846
|
+
: { outcome: "person_failed", person: null };
|
|
819
847
|
}
|
|
848
|
+
const person = {
|
|
849
|
+
workspaceId: checkout.workspaceId,
|
|
850
|
+
personId: result.value.personId,
|
|
851
|
+
};
|
|
820
852
|
switch (result.value.billing) {
|
|
821
853
|
case "written":
|
|
822
|
-
return "person_written";
|
|
854
|
+
return { outcome: "person_written", person };
|
|
823
855
|
case "kept":
|
|
824
|
-
return "person_kept";
|
|
856
|
+
return { outcome: "person_kept", person };
|
|
825
857
|
default:
|
|
826
|
-
return "person_unchanged";
|
|
858
|
+
return { outcome: "person_unchanged", person };
|
|
827
859
|
}
|
|
828
860
|
} catch {
|
|
829
|
-
return "person_failed";
|
|
861
|
+
return { outcome: "person_failed", person: null };
|
|
830
862
|
}
|
|
831
863
|
}
|
|
832
864
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace's offerings as plan keys (#8514 D9) — moved here unchanged
|
|
3
|
+
* from `routes/workspaces/plan.ts` by #8757 D4, so the magnet PATCH's
|
|
4
|
+
* save-time offering check and the lead route's submit-time re-check
|
|
5
|
+
* (`services/magnet-completion.ts`) read the same list `GET …/plan-offerings`
|
|
6
|
+
* serves.
|
|
7
|
+
*
|
|
8
|
+
* The workspace's OWN Service then Product pages: `key` is the page slug's
|
|
9
|
+
* last segment when it is an offering key (else the page is skipped), the
|
|
10
|
+
* first page per key wins, at most 200; `name` is the page title. Off the
|
|
11
|
+
* database content adapter only the default workspace has content
|
|
12
|
+
* (`workspaceContentAdapter`), so every other workspace reads `[]`.
|
|
13
|
+
*
|
|
14
|
+
* Never throws: a failed content read answers `{ failed }` with the error's
|
|
15
|
+
* class name only, so a caller can tell "none published" from "could not
|
|
16
|
+
* tell" — a failure is never a pass.
|
|
17
|
+
*
|
|
18
|
+
* Base tree (SEC-8757-D): no `oss: false` marker and no `MODULE_SLICES`
|
|
19
|
+
* entry, because the never-gate lead route reaches it.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
PLAN_OFFERINGS_MAX,
|
|
23
|
+
planOfferingSchema,
|
|
24
|
+
type PlanOfferingItem,
|
|
25
|
+
} from "@working-theory/validation";
|
|
26
|
+
|
|
27
|
+
import { workspaceContentAdapter } from "./public-site-resolver.js";
|
|
28
|
+
|
|
29
|
+
/** The workspace's Service then Product pages as plan keys (D9). */
|
|
30
|
+
export async function listPlanOfferings(
|
|
31
|
+
workspaceId: string,
|
|
32
|
+
): Promise<PlanOfferingItem[] | { failed: string }> {
|
|
33
|
+
try {
|
|
34
|
+
const adapter = await workspaceContentAdapter(workspaceId);
|
|
35
|
+
if (!adapter) return [];
|
|
36
|
+
const [services, products] = await Promise.all([
|
|
37
|
+
adapter.listPages("Service"),
|
|
38
|
+
adapter.listPages("Product"),
|
|
39
|
+
]);
|
|
40
|
+
const offerings: PlanOfferingItem[] = [];
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
const typed = [
|
|
43
|
+
...services.map((page) => ({ page, type: "service" as const })),
|
|
44
|
+
...products.map((page) => ({ page, type: "product" as const })),
|
|
45
|
+
];
|
|
46
|
+
for (const { page, type } of typed) {
|
|
47
|
+
if (offerings.length >= PLAN_OFFERINGS_MAX) break;
|
|
48
|
+
const key = page.slug.split("/").filter(Boolean).pop() ?? "";
|
|
49
|
+
const name = page.title.trim().slice(0, 200);
|
|
50
|
+
if (!planOfferingSchema.safeParse(key).success || name === "" || seen.has(key)) continue;
|
|
51
|
+
seen.add(key);
|
|
52
|
+
offerings.push({ key, name, type });
|
|
53
|
+
}
|
|
54
|
+
return offerings;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// The error's class name only — never its message, which may name a slug.
|
|
57
|
+
return { failed: error instanceof Error ? error.name : typeof error };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
LISTING_ENTRY_TYPE_DEFAULT,
|
|
66
66
|
PROJECT_LISTING_CONCURRENT_LIMIT,
|
|
67
67
|
PROJECT_LISTING_NON_TERMINAL_STATUSES,
|
|
68
|
+
RESERVED_FLOW_SLUGS,
|
|
68
69
|
} from "@working-theory/validation";
|
|
69
70
|
import type {
|
|
70
71
|
FlowAddressValue,
|
|
@@ -184,6 +185,10 @@ const SLUG_COLLISION_SCAN_LIMIT = 200;
|
|
|
184
185
|
* free slug and race to the insert; the loser hits the unique constraint and
|
|
185
186
|
* is reported as `slug_taken`, exactly as it was when the client supplied the
|
|
186
187
|
* slug itself.
|
|
188
|
+
*
|
|
189
|
+
* #8661 D4 — a listing's slug becomes its diagnostic flow's slug on approval,
|
|
190
|
+
* so the standard magnets' reserved slugs are treated as taken: a project
|
|
191
|
+
* named "Contact" is stored at `contact-2`.
|
|
187
192
|
*/
|
|
188
193
|
async function firstFreeSlug(siteId: string, base: string): Promise<string> {
|
|
189
194
|
const rows = await prisma.projectListing.findMany({
|
|
@@ -191,7 +196,7 @@ async function firstFreeSlug(siteId: string, base: string): Promise<string> {
|
|
|
191
196
|
select: { slug: true },
|
|
192
197
|
take: SLUG_COLLISION_SCAN_LIMIT,
|
|
193
198
|
});
|
|
194
|
-
const taken = new Set(rows.map((r) => r.slug));
|
|
199
|
+
const taken = new Set<string | null>([...RESERVED_FLOW_SLUGS, ...rows.map((r) => r.slug)]);
|
|
195
200
|
if (!taken.has(base)) return base;
|
|
196
201
|
|
|
197
202
|
// Bounded by `taken.size + 1` iterations — one of the first `n` candidates
|
|
@@ -246,7 +246,13 @@ export async function resolveCallerSite(
|
|
|
246
246
|
* `request.log` only; the routes answer both reasons the same way.
|
|
247
247
|
*/
|
|
248
248
|
export type CallerWorkspace =
|
|
249
|
-
| {
|
|
249
|
+
| {
|
|
250
|
+
kind: "resolved";
|
|
251
|
+
workspaceId: string;
|
|
252
|
+
viaSpoke: boolean;
|
|
253
|
+
/** #8771 D3 — set exactly when `viaSpoke` is true: the registered spoke's id. */
|
|
254
|
+
spokeId?: string;
|
|
255
|
+
}
|
|
250
256
|
| { kind: "unresolved"; reason: "spoke-refused" | "alias-unresolved" };
|
|
251
257
|
|
|
252
258
|
/**
|
|
@@ -270,6 +276,7 @@ export async function resolveCallerWorkspace(
|
|
|
270
276
|
kind: "resolved",
|
|
271
277
|
workspaceId: caller.workspaceId,
|
|
272
278
|
viaSpoke: true,
|
|
279
|
+
spokeId: caller.spokeId,
|
|
273
280
|
};
|
|
274
281
|
}
|
|
275
282
|
// Deferred, like every database import in this file.
|
|
@@ -276,71 +276,79 @@ describe("MCP catalog ↔ server drift — UNSERVED_MCP_TOOLS subtracts from the
|
|
|
276
276
|
});
|
|
277
277
|
});
|
|
278
278
|
|
|
279
|
-
describe(
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
279
|
+
describe.each([
|
|
280
|
+
["hub", []],
|
|
281
|
+
["spoke", ["create_project"]],
|
|
282
|
+
] as const)(
|
|
283
|
+
"MCP catalog ↔ server agreement after a bounded re-read (#7156 AC-1) — %s seam",
|
|
284
|
+
(_topology, unserved) => {
|
|
285
|
+
const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
|
|
286
286
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
} else {
|
|
293
|
-
process.env.AILK_ENABLED_MODULES = priorEnabledModules;
|
|
294
|
-
}
|
|
295
|
-
});
|
|
287
|
+
beforeEach(() => {
|
|
288
|
+
process.env.AILK_ENABLED_MODULES = PAID_MODULES;
|
|
289
|
+
mockSeam.unserved = unserved;
|
|
290
|
+
jest.useFakeTimers();
|
|
291
|
+
});
|
|
296
292
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
293
|
+
afterEach(() => {
|
|
294
|
+
resetMcpCommerceClaim();
|
|
295
|
+
mockSeam.unserved = undefined;
|
|
296
|
+
jest.useRealTimers();
|
|
297
|
+
if (priorEnabledModules === undefined) {
|
|
298
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
299
|
+
} else {
|
|
300
|
+
process.env.AILK_ENABLED_MODULES = priorEnabledModules;
|
|
301
|
+
}
|
|
304
302
|
});
|
|
305
303
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
304
|
+
it("a new server and listRegisteredTools() match the catalog once the re-read resolves", async () => {
|
|
305
|
+
__setMcpFeatureGateForTest("refuse");
|
|
306
|
+
await initMcpCommerceClaim({
|
|
307
|
+
checkLicense: resolving({
|
|
308
|
+
tier: "pro",
|
|
309
|
+
features: ["scheduling", "flows-deliverables", "commerce"],
|
|
310
|
+
}),
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const bootOptions: McpCatalogOptions = {
|
|
314
|
+
addons: { commerce: true },
|
|
315
|
+
features: ["scheduling", "flows-deliverables"],
|
|
316
|
+
featureGate: "refuse",
|
|
317
|
+
};
|
|
318
|
+
const bootNames = Object.keys(
|
|
319
|
+
registeredToolsOf(createMcpServer()),
|
|
320
|
+
).sort();
|
|
321
|
+
// Both counts below are of the served catalog — the full catalog (20 at
|
|
322
|
+
// boot, 14 after the re-read) minus this seam's UNSERVED_MCP_TOOLS (#8665).
|
|
323
|
+
expect(bootNames).toEqual(
|
|
324
|
+
servedCatalog(bootOptions)
|
|
325
|
+
.map((t) => t.name)
|
|
326
|
+
.sort(),
|
|
327
|
+
);
|
|
328
|
+
expect(bootNames).toHaveLength(20 - UNSERVED_MCP_TOOLS.length);
|
|
329
|
+
|
|
330
|
+
startMcpClaimRefresh({ checkLicense: resolving({ features: [] }) });
|
|
331
|
+
await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
|
|
332
|
+
|
|
333
|
+
const refreshedOptions: McpCatalogOptions = {
|
|
334
|
+
addons: { commerce: false },
|
|
335
|
+
features: [],
|
|
336
|
+
featureGate: "refuse",
|
|
337
|
+
};
|
|
338
|
+
const servedNames = servedCatalog(refreshedOptions)
|
|
314
339
|
.map((t) => t.name)
|
|
315
|
-
.sort()
|
|
316
|
-
|
|
317
|
-
expect(bootNames).toHaveLength(20 - UNSERVED_MCP_TOOLS.length);
|
|
318
|
-
|
|
319
|
-
startMcpClaimRefresh({ checkLicense: resolving({ features: [] }) });
|
|
320
|
-
await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
|
|
321
|
-
|
|
322
|
-
const refreshedOptions: McpCatalogOptions = {
|
|
323
|
-
addons: { commerce: false },
|
|
324
|
-
features: [],
|
|
325
|
-
featureGate: "refuse",
|
|
326
|
-
};
|
|
327
|
-
const catalogNames = getMcpToolCatalog(refreshedOptions)
|
|
328
|
-
.map((t) => t.name)
|
|
329
|
-
.sort();
|
|
330
|
-
expect(catalogNames).toHaveLength(14);
|
|
331
|
-
|
|
332
|
-
const servedNames = servedCatalog(refreshedOptions)
|
|
333
|
-
.map((t) => t.name)
|
|
334
|
-
.sort();
|
|
340
|
+
.sort();
|
|
341
|
+
expect(servedNames).toHaveLength(14 - UNSERVED_MCP_TOOLS.length);
|
|
335
342
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
343
|
+
const serverNames = Object.keys(
|
|
344
|
+
registeredToolsOf(createMcpServer()),
|
|
345
|
+
).sort();
|
|
346
|
+
expect(serverNames).toEqual(servedNames);
|
|
340
347
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
348
|
+
const listedNames = listRegisteredTools()
|
|
349
|
+
.map((t) => t.name)
|
|
350
|
+
.sort();
|
|
351
|
+
expect(listedNames).toEqual(servedNames);
|
|
352
|
+
});
|
|
353
|
+
},
|
|
354
|
+
);
|
|
@@ -92,6 +92,20 @@ CONTENT_ADAPTER=mdx
|
|
|
92
92
|
# origin the browser never sees.
|
|
93
93
|
# NEXT_PUBLIC_AILK_API_URL=http://localhost:3002
|
|
94
94
|
|
|
95
|
+
# =============================================================================
|
|
96
|
+
# The /contact lead's site marker (server-side only) — #8747
|
|
97
|
+
# =============================================================================
|
|
98
|
+
# This site's slug. The /contact Server Action writes it into every lead as
|
|
99
|
+
# payload.site (with payload.page = "contact"), so a hub serving several sites
|
|
100
|
+
# — or one site routing leads to two workspaces — can tell where a lead came
|
|
101
|
+
# from. Use the site's short name, lowercase letters, digits and hyphens (for
|
|
102
|
+
# example "wendihenry"); apps/mcp's capture_lead reads the same variable. Unset
|
|
103
|
+
# or blank leaves the lead with no site marker — nothing is invented, and a
|
|
104
|
+
# visitor-sent "site" field is dropped. The marker is attribution only: the
|
|
105
|
+
# lead route is public, so never gate access on it. The action sends no
|
|
106
|
+
# siteId; the API names the workspace itself (by AILK_SPOKE_KEY on a hub).
|
|
107
|
+
# AILK_LEAD_SITE=
|
|
108
|
+
|
|
95
109
|
# =============================================================================
|
|
96
110
|
# Listicle directory (#7521) — switched on by the site's listing key (#7872)
|
|
97
111
|
# =============================================================================
|
|
@@ -110,6 +110,26 @@ function — the page under test throws instead of taking a branch. Declare
|
|
|
110
110
|
every predicate the pages call, binding the two reset ones to the same
|
|
111
111
|
`jest.fn()` when the case does not need them to disagree.
|
|
112
112
|
|
|
113
|
+
**Sign-up agreements are refused and written on the server (#8765).**
|
|
114
|
+
`lib/sign-up-agreements.ts` is a Better Auth plugin plus two database hooks
|
|
115
|
+
that `auth.ts` installs. While `isSignUpOpen()`, `POST /api/auth/sign-up/email`
|
|
116
|
+
without every sign-up agreement in its `agreements` body key answers
|
|
117
|
+
`400 AGREEMENTS_REQUIRED` before the email lookup (so the answer never depends
|
|
118
|
+
on whether the address is taken), and `503 AGREEMENTS_UNAVAILABLE` when the
|
|
119
|
+
API's `GET /v1/consent/sign-up` evidence cannot be read (60 s fresh, 10 min
|
|
120
|
+
stale-on-error, one cache entry per enabled locale). An accepted sign-up — or
|
|
121
|
+
an OAuth sign-up whose state carries the card's agreements — writes one
|
|
122
|
+
`sign_up` grant per agreement through the auth client's transaction
|
|
123
|
+
(`prismaAdapter(…, { transaction: true })`), so the `User`, its `Account` and
|
|
124
|
+
the grants commit together or not at all; this hook is the ledger's only
|
|
125
|
+
writer outside `apps/api`'s `services/consent.ts`, and it writes only rows of
|
|
126
|
+
a user it is creating. A user created without agreements (a first social
|
|
127
|
+
sign-in from `/sign-in`) has none, and the account interstitial asks them
|
|
128
|
+
(`(authed)/account/layout.tsx`, which fails closed on any read but a 2xx or an
|
|
129
|
+
older hub's 404). The carried `__Host-ailk-signup-agreements` cookie is
|
|
130
|
+
retired. Policy links on every agreement surface come from
|
|
131
|
+
`lib/consent-policies.ts`'s `consentPoliciesFor(locale)`.
|
|
132
|
+
|
|
113
133
|
## Contact page
|
|
114
134
|
|
|
115
135
|
The contact page (`apps/web/app/[locale]/contact/page.tsx`) renders the
|
|
@@ -123,15 +143,123 @@ the site's contact email, never a form that would report a message as sent
|
|
|
123
143
|
when it reached nobody. A failed status read counts as accepting, so the
|
|
124
144
|
form renders and its submit reports honestly.
|
|
125
145
|
|
|
146
|
+
The form's fields come from the workspace's **Contact form magnet** (#8661):
|
|
147
|
+
every workspace is seeded one, live at slug `contact`, and the page is rendered
|
|
148
|
+
on every request (`force-dynamic`) and reads it through the public flow read,
|
|
149
|
+
`flows.get("contact")`, shared across requests for 15 seconds per server
|
|
150
|
+
instance (#8731) — so a burst of views is one hub read, and an Admin edit shows
|
|
151
|
+
within 15 seconds. The capture-status read is shared the same way. The API resolves the workspace (the site alias
|
|
152
|
+
keyless, a spoke by its key), so the page names none. `contact-form-magnet.ts`
|
|
153
|
+
maps the config's first step onto the block's fields, terms statement and
|
|
154
|
+
submit label. The order is: a usable magnet (its first step has `name` and
|
|
155
|
+
`email` fields), else the page's `contact-form` section fields when it
|
|
156
|
+
declares any, else the built-in form, which is the seeded config itself. Any
|
|
157
|
+
failed read (absent, draft, a Free build with no flow route, a throw) is the
|
|
158
|
+
fallback, never an error. A magnet-driven post carries a hidden
|
|
159
|
+
`source: "flow:contact"`, so it is an Inbox submission of the Contact form
|
|
160
|
+
with every answer shown; a fallback post is `contact-form` (#8747). The terms statement
|
|
161
|
+
links Terms, Privacy Policy and Working Agreement from a page-built
|
|
162
|
+
`policies` map, and `captureLeadAction` refuses a post without
|
|
163
|
+
`consent=true` whatever its source (#8730 D2 — the hidden `source` picks the
|
|
164
|
+
stamp, never the gate) and keeps only the contact sources
|
|
165
|
+
(`CONTACT_LEAD_SOURCES`: `contact-form`, `conversational-intake`,
|
|
166
|
+
`flow:contact`). An accepted post sends `consent: true` to the API, which
|
|
167
|
+
refuses a contact lead without it (`400 consent_required`, shown as the same
|
|
168
|
+
field error) and records `payload.consent` itself.
|
|
169
|
+
|
|
170
|
+
The action writes through `src/lib/lead-write.ts` (#8747), the site's lead
|
|
171
|
+
writer: `payload.site` is `AILK_LEAD_SITE` (none when unset) and
|
|
172
|
+
`payload.page` is `contact`, both spread last so no form field overrides them,
|
|
173
|
+
and no `siteId` is sent. Before the write it runs a per-visitor limit
|
|
174
|
+
(`src/lib/rate-limit.ts`, 30 posts per 10 minutes, per instance; skipped when
|
|
175
|
+
no client address is readable), because the hub sees only this site's egress
|
|
176
|
+
address. An API that cannot be reached — a
|
|
177
|
+
thrown submit, a 502 or a 504 — renders the contact email, like the 503
|
|
178
|
+
`lead_capture_not_configured`.
|
|
179
|
+
|
|
180
|
+
**The account link (#8757 D8, D9).** When the Contact form magnet is set to
|
|
181
|
+
`invite` in Admin, the API's 201 carries `next: { completion: "invite" }` and
|
|
182
|
+
the action answers `{ kind: "success", next: { href, label } }`: the success
|
|
183
|
+
alert gains "Track this in your account" (`contact.trackInAccount`, testid
|
|
184
|
+
`contact-success-next`), built by `lib/account-invite.ts` from
|
|
185
|
+
`SIGN_IN_PROVIDER` — `site` links `/sign-in?next=<locale>/account&email=…`,
|
|
186
|
+
`hub` links `/api/hub-session/sign-in?next=/account&email=…`, `none` (or an
|
|
187
|
+
email `accountEmailHintSchema` refuses) gives no link. Anything else,
|
|
188
|
+
`checkout` included (Phase 1), is exactly `{ kind: "success" }`. The link
|
|
189
|
+
signs no one in: `/sign-in` reads one valid `email` as the card's starting
|
|
190
|
+
value (`AuthCard`'s `defaultEmail`), the spoke's
|
|
191
|
+
`/api/hub-session/sign-in` forwards it as `login_hint`, and the hub's
|
|
192
|
+
authorize route carries a valid hint only onto its no-session `/sign-in`
|
|
193
|
+
redirect, never into a code, cookie or continuation ref (a repeated
|
|
194
|
+
`login_hint` is `400 invalid_request`). `email` and `login_hint` are in the
|
|
195
|
+
telemetry scrub lists (`lib/telemetry-scrub.ts`, `packages/analytics`).
|
|
196
|
+
|
|
197
|
+
**The same link on the magnet walk and /careers (#8771).** The `/careers/<slug>` job page's action reads the API's `next` the same way (`apply.actions.ts`): `invite` returns the link and `contact.trackInAccount`, anything else — or a throw after the write — exactly `{ kind: "success" }`, so an error never invites a duplicate application. The walk (`/flows/<slug>`, `FlowStepperClient`) tracks the first `mapsTo: "email"` answer it submitted; on a completing response with `next: { completion: "invite" }` it asks the server action `accountInviteLinkAction` (the builder reads the generated topology, so it never runs in the client) and shows the link inside `FlowStepper`'s finished card (its `completedAction` slot), or — for a flow with a `thankYou` block — writes one `sessionStorage` item, `ailk:account-invite`, that `/thanks/<slug>`'s `AccountInviteLink` reads once and removes (the page passes it to `renderPage` as `trailing`, so it sits inside `<main>`). Each link sits in a `data-sentry-block` element (Session Replay records `href`s); no walk URL ever carries the email.
|
|
198
|
+
|
|
199
|
+
A `contact-form` section in the `column` variant (the minimalism design's
|
|
200
|
+
Contact page) renders one centred heading, one line and the form, and the
|
|
201
|
+
page passes the block `look="column"` (the stacked look, CSS in
|
|
202
|
+
`packages/theme/src/tokens.css`) and `consentGate="on-submit"`: Send stays
|
|
203
|
+
enabled and an unticked submit shows the terms error. Every other look keeps
|
|
204
|
+
Send disabled until the box is ticked.
|
|
205
|
+
|
|
126
206
|
The chat-style intake is not mounted by default.
|
|
127
207
|
`ConversationalIntakeWrapper.tsx` remains for a site that prefers it: it hosts
|
|
128
208
|
`@working-theory/ui`'s `ConversationalIntake` block (contract in
|
|
129
|
-
`packages/ui/src/blocks/conversational-intake/README.md`),
|
|
130
|
-
|
|
209
|
+
`packages/ui/src/blocks/conversational-intake/README.md`), asks for the terms
|
|
210
|
+
in its own last turn (#8730 D2), submits through the same
|
|
211
|
+
`captureLeadAction` with `consent=true` only when the accept pill was tapped,
|
|
212
|
+
and on a declined terms turn, Skip or a failed submit swaps to
|
|
131
213
|
`LeadCaptureForm`, prefilled with what was already captured. The canonical
|
|
132
214
|
embedding for it is behind the hero CTA's lightbox gate
|
|
133
215
|
(`packages/ui/src/sections/FormLightbox.tsx`, #4577), not a whole-page mount.
|
|
134
216
|
|
|
217
|
+
## Spoke checkout
|
|
218
|
+
|
|
219
|
+
A spoke that carries `commerce` sells through the standard spoke checkout
|
|
220
|
+
(#8748), six files every such spoke shares and `create-ailk update` keeps
|
|
221
|
+
current. A hub, or a spoke without `commerce`, never gets them: the hub's
|
|
222
|
+
checkout is `apps/api`'s.
|
|
223
|
+
|
|
224
|
+
- **The files.** `app/api/checkout/[offering]/route.ts` (the buy route),
|
|
225
|
+
`app/api/webhooks/stripe/route.ts`, `app/[locale]/checkout/thank-you/page.tsx`,
|
|
226
|
+
`app/[locale]/checkout/unavailable/page.tsx`, `lib/checkout.ts` (the
|
|
227
|
+
standard) and `lib/checkout-config.ts` (the site's data). Every guarantee —
|
|
228
|
+
live-key admission, the buy guards, the webhook's verification and filters,
|
|
229
|
+
the duplicate guard — lives in `@working-theory/spoke-checkout` and arrives by
|
|
230
|
+
a package bump.
|
|
231
|
+
- **One data file.** `lib/checkout-config.ts` is the one file a site edits:
|
|
232
|
+
the `const SITE` stamp and the `offerings` map, no import and no code.
|
|
233
|
+
`create-ailk update` never rewrites it. Name an offering's slug after the last
|
|
234
|
+
segment of the Service or Product page that sells it.
|
|
235
|
+
- **A slug that is not an offering answers 404**, before any guard, limiter,
|
|
236
|
+
admission or buyer lookup. Every other refusal is a 303 to
|
|
237
|
+
`/checkout/unavailable`.
|
|
238
|
+
- **Spoke only.** Both routes answer 404 and both pages call `notFound()`
|
|
239
|
+
unless `lib/topology.generated.ts`'s `TOPOLOGY` is `"spoke"`, so this
|
|
240
|
+
monorepo's hub-shaped `apps/web` serves none of them.
|
|
241
|
+
- **One sender, two importers.** `lib/checkout.ts`'s private fulfilment is the
|
|
242
|
+
one `apps/web` file that sends an `Idempotency-Key`: one `leads.submit` per
|
|
243
|
+
paid session, `payload.site` set to the checkout stamp (never
|
|
244
|
+
`AILK_LEAD_SITE`), the session id as the key, and a body that must stay
|
|
245
|
+
byte-identical between attempts, because the hub replays only an identical
|
|
246
|
+
body. The payload's last key, `termsAccepted`, is the session's `ailk_terms`
|
|
247
|
+
stamp when the buyer accepted Stripe's terms box, else `null`; the hub writes
|
|
248
|
+
the buyer's checkout consent grant from it (#8778). The hub leaves that key
|
|
249
|
+
out of its replay comparison, so a redelivery across the update still
|
|
250
|
+
replays. Only the two route files may import `lib/checkout.ts` or the package
|
|
251
|
+
(`tests/architecture/idempotency-key-producers.test.ts`, `architecture.yaml`);
|
|
252
|
+
a spoke that signs in through its hub adds a third, its rendered billing
|
|
253
|
+
action, which its own `architecture.yaml` admits. The thank-you page may
|
|
254
|
+
import the package for `readPaidCheckoutEmail` alone, never
|
|
255
|
+
`lib/checkout.ts` (#8757 D13): it reads `session_id`, and for a paid
|
|
256
|
+
session of this site under 24 hours old shows "Open your account" with the
|
|
257
|
+
buyer's email (`accountInviteHref`), else the same link with no email. Each
|
|
258
|
+
visitor gets 10 reads per 10 minutes (`src/lib/rate-limit.ts`); the page
|
|
259
|
+
sends no referrer, renders dynamically and never touches a cookie.
|
|
260
|
+
`spokeCheckoutConfig` reads only `site` and `offerings` from the site's file,
|
|
261
|
+
so admission always reads `process.env`.
|
|
262
|
+
|
|
135
263
|
## The same-origin relay (`/v1/*`)
|
|
136
264
|
|
|
137
265
|
A visitor's browser never calls the hub. Lead capture, newsletter signup, callback, scheduling and the commerce invoice call post to this site's own `/v1/...` address, and `app/v1/[...path]/route.ts` hands each request to `lib/relay.ts` (`handleRelay`), which forwards it server-side through `@working-theory/api-client`'s `relayToApi` with the site's own `AILK_SPOKE_KEY` when it has one (#7644). The browser never holds the key, so a hub can refuse unknown callers (#7633) without taking a site's forms down.
|
|
@@ -158,4 +286,12 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
|
|
|
158
286
|
- Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
|
|
159
287
|
- Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
|
|
160
288
|
|
|
161
|
-
Last updated: 2026-10-
|
|
289
|
+
Last updated: 2026-10-16 — § Spoke checkout: the purchase lead's `termsAccepted` key, the stamp of the terms the buyer accepted, from which the hub writes the checkout consent grant (#8778) — verified against `lib/checkout.ts` and `packages/spoke-checkout/src/webhook.ts`.
|
|
290
|
+
|
|
291
|
+
Prior: 2026-10-16 — § Spoke checkout: the thank-you page reads the paid session's email for "Open your account" (#8757 D13, #8800) — verified against `app/[locale]/checkout/thank-you/page.tsx` and `packages/spoke-checkout/src/paid-email.ts`.
|
|
292
|
+
|
|
293
|
+
Prior: 2026-10-15 — § The `(auth)` route group: sign-up agreements are refused and written on the server, the carried cookie is retired, and the account interstitial asks a user with no current grant (#8765) — verified against `lib/sign-up-agreements.ts`, `auth.ts`, `lib/consent-policies.ts` and `app/[locale]/(authed)/account/layout.tsx`.
|
|
294
|
+
|
|
295
|
+
Prior: 2026-10-15 — § Spoke checkout: the standard spoke checkout files, the one data file, the 404 and the fulfilment's one-key rule (#8748) — verified against `app/api/checkout/[offering]/route.ts`, `app/api/webhooks/stripe/route.ts`, `app/[locale]/checkout/*/page.tsx`, `lib/checkout.ts` and `lib/checkout-config.ts`.
|
|
296
|
+
|
|
297
|
+
Prior: 2026-10-15 — § Contact page: the account link an `invite` magnet adds, and the sign-in email hint (#8757).
|
|
@@ -24,10 +24,14 @@ const catalog = msgs as unknown as Record<string, Record<string, string>>;
|
|
|
24
24
|
// getTranslations({ namespace, locale }) — renderPage (post-migration)
|
|
25
25
|
export const getTranslations = async (
|
|
26
26
|
arg: string | { namespace?: string; locale?: string },
|
|
27
|
-
): Promise<(key: string) => string> => {
|
|
27
|
+
): Promise<((key: string) => string) & { has: (key: string) => boolean }> => {
|
|
28
28
|
const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
|
|
29
29
|
const messages = catalog[ns];
|
|
30
|
-
|
|
30
|
+
const t = (key: string): string => messages?.[key] ?? `${ns}.${key}`;
|
|
31
|
+
// next-intl's `t.has` — an optional catalog key (#8790's prospect line) is
|
|
32
|
+
// present only when the site added it.
|
|
33
|
+
t.has = (key: string): boolean => messages?.[key] !== undefined;
|
|
34
|
+
return t;
|
|
31
35
|
};
|
|
32
36
|
|
|
33
37
|
export const getRequestConfig = (fn: unknown): unknown => fn;
|
|
@@ -141,9 +141,9 @@ describe("the (auth) layout", () => {
|
|
|
141
141
|
expect(document.querySelector(".ailk-footer")).not.toBeInTheDocument();
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
-
it("renders
|
|
144
|
+
it("renders no brand row (the mark is in each card, #8790 D3) and the slim footer's three links", async () => {
|
|
145
145
|
await renderLayout();
|
|
146
|
-
expect(screen.
|
|
146
|
+
expect(screen.queryByTestId("auth-shell-brand")).not.toBeInTheDocument();
|
|
147
147
|
const footer = screen.getByTestId("auth-shell-footer");
|
|
148
148
|
expect(footer.querySelectorAll("a")).toHaveLength(3);
|
|
149
149
|
expect(
|
|
@@ -193,9 +193,10 @@ describe("the moved sign-in card", () => {
|
|
|
193
193
|
expect(screen.queryByTestId("auth-forgot")).not.toBeInTheDocument();
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
-
it("renders
|
|
196
|
+
it("renders the site's mark inside the card, not in the shell (#8790 D3)", async () => {
|
|
197
197
|
await renderPage(SignInPage);
|
|
198
|
-
expect(screen.
|
|
198
|
+
expect(screen.getByTestId("auth-card-logo")).toBeInTheDocument();
|
|
199
|
+
expect(screen.queryByTestId("auth-shell-brand")).not.toBeInTheDocument();
|
|
199
200
|
});
|
|
200
201
|
});
|
|
201
202
|
|
|
@@ -32,12 +32,14 @@ import {
|
|
|
32
32
|
type AuthSubmitData,
|
|
33
33
|
} from "@working-theory/ui/blocks";
|
|
34
34
|
import { createAuthClient } from "better-auth/react";
|
|
35
|
-
import type { ReactElement } from "react";
|
|
35
|
+
import type { ReactElement, ReactNode } from "react";
|
|
36
36
|
import { useState } from "react";
|
|
37
37
|
|
|
38
38
|
const authClient = createAuthClient();
|
|
39
39
|
|
|
40
40
|
export interface ForgotPasswordCardProps {
|
|
41
|
+
/** The site's mark, centred in the card at 48px (#8790 D3). */
|
|
42
|
+
logo?: ReactNode;
|
|
41
43
|
/** Absolute same-origin URL of `/reset-password`, resolved server-side. */
|
|
42
44
|
resetUrl: string;
|
|
43
45
|
/** Href for the card footer's "Back to sign in". */
|
|
@@ -53,6 +55,7 @@ export function ForgotPasswordCard({
|
|
|
53
55
|
signInHref,
|
|
54
56
|
labels,
|
|
55
57
|
errorLabels,
|
|
58
|
+
logo,
|
|
56
59
|
}: ForgotPasswordCardProps): ReactElement {
|
|
57
60
|
const [sent, setSent] = useState(false);
|
|
58
61
|
const [sentTo, setSentTo] = useState("");
|
|
@@ -85,6 +88,7 @@ export function ForgotPasswordCard({
|
|
|
85
88
|
return (
|
|
86
89
|
<div className="flex flex-col gap-4" data-testid="forgot-password-page">
|
|
87
90
|
<AuthCard
|
|
91
|
+
{...(logo !== undefined ? { logo } : {})}
|
|
88
92
|
intent={sent ? "sent" : "forgot"}
|
|
89
93
|
signInHref={signInHref}
|
|
90
94
|
labels={labels}
|
|
@@ -40,6 +40,7 @@ import type { ReactElement } from "react";
|
|
|
40
40
|
import { resetSendConfigured } from "../../../../lib/auth-env";
|
|
41
41
|
|
|
42
42
|
import { ForgotPasswordCard } from "./forgot-password-card";
|
|
43
|
+
import { siteLogo } from "../../../../lib/site-logo";
|
|
43
44
|
|
|
44
45
|
interface Props {
|
|
45
46
|
params: Promise<{ locale: string }>;
|
|
@@ -100,6 +101,7 @@ export default async function ForgotPasswordPage({
|
|
|
100
101
|
|
|
101
102
|
return (
|
|
102
103
|
<ForgotPasswordCard
|
|
104
|
+
logo={siteLogo()}
|
|
103
105
|
resetUrl={resetUrl}
|
|
104
106
|
signInHref={signInHref}
|
|
105
107
|
labels={labels}
|