create-ailk 0.26.0 → 0.28.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 +86 -9
- package/dist/add-module-core.js +2 -1
- package/dist/add-module.d.ts +2 -1
- package/dist/add-module.js +9 -4
- package/dist/add-route.js +46 -19
- package/dist/body-heading.d.ts +56 -0
- package/dist/body-heading.js +394 -0
- package/dist/cli.js +28 -1
- package/dist/content-payloads.js +2 -2
- package/dist/derive-architecture.js +8 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/lib/auth-secret-file.d.ts +6 -2
- package/dist/lib/auth-secret-file.js +46 -20
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/site-modules.d.ts +8 -0
- package/dist/lib/site-modules.js +34 -0
- package/dist/module-architecture.js +25 -14
- package/dist/paid-sections.d.ts +17 -0
- package/dist/paid-sections.js +39 -0
- package/dist/programmatic.d.ts +16 -0
- package/dist/programmatic.js +73 -4
- package/dist/remove-module.d.ts +98 -0
- package/dist/remove-module.js +620 -0
- package/dist/seed-flows.d.ts +2 -2
- package/dist/seed-flows.js +16 -9
- package/dist/site-roster.js +4 -1
- package/dist/starter-grid.d.ts +26 -0
- package/dist/starter-grid.js +34 -0
- package/dist/surfaces.d.ts +26 -0
- package/dist/surfaces.js +112 -11
- package/dist/sync-routes.d.ts +7 -0
- package/dist/sync-routes.js +53 -6
- package/dist/template-pages.d.ts +36 -0
- package/dist/template-pages.js +129 -0
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +3 -1
- package/dist/update.js +90 -6
- package/package.json +3 -3
- package/release-scope.json +2 -1
- package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
- package/templates/.env.example +12 -2
- package/templates/CLAUDE.md +20 -20
- package/templates/README.md +19 -19
- package/templates/apps/api/.env.example +16 -5
- package/templates/apps/api/CLAUDE.md +32 -11
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
- package/templates/apps/api/src/vercel-handler.ts +8 -1
- package/templates/apps/api/vercel.json +7 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +27 -0
- package/templates/apps/web/CLAUDE.md +56 -8
- package/templates/apps/web/__tests__/setup.ts +7 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
- package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
- package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
- package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/layout.tsx +161 -128
- package/templates/apps/web/app/[locale]/page.tsx +51 -3
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
- package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
- package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
- package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
- package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
- package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +322 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
- package/templates/apps/web/app/llms.txt/route.ts +41 -3
- package/templates/apps/web/app/robots.txt/route.ts +5 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
- package/templates/apps/web/auth.ts +102 -50
- package/templates/apps/web/components/signed-in-nav.tsx +8 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
- package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
- package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
- package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +484 -4
- package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
- package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
- package/templates/apps/web/lib/auth-secret.ts +57 -0
- package/templates/apps/web/lib/bot-check.ts +138 -0
- package/templates/apps/web/lib/consent-visitor.ts +81 -7
- package/templates/apps/web/lib/deck-page.ts +6 -3
- package/templates/apps/web/lib/docs-index.ts +100 -0
- package/templates/apps/web/lib/docs-path.ts +94 -0
- package/templates/apps/web/lib/hub-children.tsx +318 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/nav-label-check.ts +61 -0
- package/templates/apps/web/lib/nav-label-keys.ts +78 -0
- package/templates/apps/web/lib/newsletter.actions.ts +31 -6
- package/templates/apps/web/lib/offerings-pricing.ts +135 -6
- package/templates/apps/web/lib/page-chrome.ts +367 -0
- package/templates/apps/web/lib/posting-title.ts +26 -4
- package/templates/apps/web/lib/pricing-page.ts +430 -0
- package/templates/apps/web/lib/relay.ts +44 -4
- package/templates/apps/web/lib/route-family.ts +69 -0
- package/templates/apps/web/lib/service-listing.ts +89 -0
- package/templates/apps/web/lib/services-path.ts +97 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
- package/templates/apps/web/lib/site-header.ts +94 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
- package/templates/apps/web/lib/template-listing.ts +169 -0
- package/templates/apps/web/messages/en.json +144 -3
- package/templates/apps/web/middleware.ts +179 -12
- package/templates/apps/web/next.config.mjs +220 -3
- package/templates/apps/web/package.json +3 -2
- package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
- package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
- package/templates/apps/web/sentry.server.config.ts +30 -2
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
- package/templates/apps/web/src/lib/rate-limit.ts +68 -27
- package/templates/apps/web/src/lib/routes.ts +15 -1
- package/versions.json +48 -48
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// #9162 (PR #9152's security review) — the deliverable grant id in the PATH of
|
|
2
|
+
// `<hub>/v1/deliverables/<grantId>`, which `deliverables.openGrant` fetches
|
|
3
|
+
// server-side, is redacted before a URL reaches Sentry. The query-param scrub
|
|
4
|
+
// has its own suite, `__tests__/telemetry-scrub.test.ts`.
|
|
5
|
+
|
|
6
|
+
import { scrubTelemetryUrl } from "../telemetry-scrub";
|
|
7
|
+
|
|
8
|
+
const GRANT = "dgrant_01920000-0000-7000-8000-000000000001";
|
|
9
|
+
|
|
10
|
+
describe("scrubTelemetryUrl — the /v1/deliverables/<grantId> path (#9162)", () => {
|
|
11
|
+
it("redacts the grant id of an absolute hub URL", () => {
|
|
12
|
+
expect(
|
|
13
|
+
scrubTelemetryUrl(`https://hub.example.com/v1/deliverables/${GRANT}`),
|
|
14
|
+
).toBe("https://hub.example.com/v1/deliverables/:grantId");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("redacts the grant id of a relative URL", () => {
|
|
18
|
+
expect(scrubTelemetryUrl(`/v1/deliverables/${GRANT}`)).toBe(
|
|
19
|
+
"/v1/deliverables/:grantId",
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("keeps the query string and fragment around the redacted id", () => {
|
|
24
|
+
expect(
|
|
25
|
+
scrubTelemetryUrl(
|
|
26
|
+
`https://hub.example.com/v1/deliverables/${GRANT}?x=1#top`,
|
|
27
|
+
),
|
|
28
|
+
).toBe("https://hub.example.com/v1/deliverables/:grantId?x=1#top");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("redacts the id and strips a token query param together", () => {
|
|
32
|
+
const out = scrubTelemetryUrl(
|
|
33
|
+
`https://hub.example.com/v1/deliverables/${GRANT}?g=${GRANT}&x=1`,
|
|
34
|
+
);
|
|
35
|
+
expect(out).toBe("https://hub.example.com/v1/deliverables/:grantId?x=1");
|
|
36
|
+
expect(out).not.toContain(GRANT);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("redacts an id the api-client percent-encoded", () => {
|
|
40
|
+
const out = scrubTelemetryUrl(
|
|
41
|
+
`https://hub.example.com/v1/deliverables/${encodeURIComponent("a b%c")}`,
|
|
42
|
+
);
|
|
43
|
+
expect(out).toBe("https://hub.example.com/v1/deliverables/:grantId");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("redacts the id under an API base path", () => {
|
|
47
|
+
expect(
|
|
48
|
+
scrubTelemetryUrl(`https://hub.example.com/api/v1/deliverables/${GRANT}`),
|
|
49
|
+
).toBe("https://hub.example.com/api/v1/deliverables/:grantId");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("redacts the id inside a span description (`METHOD url`)", () => {
|
|
53
|
+
expect(
|
|
54
|
+
scrubTelemetryUrl(`GET https://hub.example.com/v1/deliverables/${GRANT}`),
|
|
55
|
+
).toBe("GET https://hub.example.com/v1/deliverables/:grantId");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("leaves the ungated open read's slug alone — a slug is not a credential", () => {
|
|
59
|
+
for (const url of [
|
|
60
|
+
"https://hub.example.com/v1/deliverables/open/q3",
|
|
61
|
+
"/v1/deliverables/open/q3?x=1",
|
|
62
|
+
"GET https://hub.example.com/v1/deliverables/open/q3",
|
|
63
|
+
]) {
|
|
64
|
+
expect(scrubTelemetryUrl(url)).toBe(url);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("redacts a grant id that only starts with `open`", () => {
|
|
69
|
+
expect(scrubTelemetryUrl("/v1/deliverables/openly_a_grant")).toBe(
|
|
70
|
+
"/v1/deliverables/:grantId",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("leaves other paths unchanged", () => {
|
|
75
|
+
for (const url of [
|
|
76
|
+
"https://hub.example.com/v1/deliverables",
|
|
77
|
+
"https://hub.example.com/v1/deliverables/",
|
|
78
|
+
"/v1/leads/submit",
|
|
79
|
+
`/reports/q3/file`,
|
|
80
|
+
]) {
|
|
81
|
+
expect(scrubTelemetryUrl(url)).toBe(url);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The template-listing mapping (#10051 D6, AC-6) — a TemplatePage's
|
|
3
|
+
* frontmatter to a hub card, an action label and the detail page's
|
|
4
|
+
* generated sections. Pure; no I/O.
|
|
5
|
+
*/
|
|
6
|
+
import { PageSectionsSchema } from "@working-theory/validation";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
templateActionLabel,
|
|
10
|
+
templateCard,
|
|
11
|
+
templateDetailSections,
|
|
12
|
+
type TemplateLabels,
|
|
13
|
+
} from "../template-listing";
|
|
14
|
+
|
|
15
|
+
const labels: TemplateLabels = {
|
|
16
|
+
getIt: "Get it",
|
|
17
|
+
startFree: "Start free",
|
|
18
|
+
viewDemo: "View demo",
|
|
19
|
+
viewTemplate: "View template",
|
|
20
|
+
included: "What's included",
|
|
21
|
+
demoBadge: "Live demo",
|
|
22
|
+
moreCategories: (count) => `+${count} more`,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const full = {
|
|
26
|
+
slug: "a",
|
|
27
|
+
frontmatter: {
|
|
28
|
+
title: "Template A",
|
|
29
|
+
description: "Description A.",
|
|
30
|
+
summary: "Summary A.",
|
|
31
|
+
thumbnail: "/img/a.png",
|
|
32
|
+
categories: ["accounting", "not-a-category", "legal"],
|
|
33
|
+
demoUrl: "https://a.salessmyth.com",
|
|
34
|
+
getItUrl: "/contact",
|
|
35
|
+
price: 0,
|
|
36
|
+
includes: [
|
|
37
|
+
{ title: "Base site", description: "Pages and structured data." },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const bare = {
|
|
43
|
+
slug: "b",
|
|
44
|
+
frontmatter: {
|
|
45
|
+
title: "Template B",
|
|
46
|
+
description: "Description B.",
|
|
47
|
+
getItUrl: "https://buy.example.com/b",
|
|
48
|
+
includes: [
|
|
49
|
+
{ title: "Base site", description: "Pages and structured data." },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
describe("templateCard", () => {
|
|
55
|
+
it("maps every present field", () => {
|
|
56
|
+
expect(templateCard(full, labels)).toEqual({
|
|
57
|
+
title: "Template A",
|
|
58
|
+
description: "Summary A.",
|
|
59
|
+
image: { src: "/img/a.png", alt: "Template A" },
|
|
60
|
+
eyebrow: "Accounting · Legal",
|
|
61
|
+
badge: "Live demo",
|
|
62
|
+
link: { label: "View template", href: "/templates/a" },
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("omits image, eyebrow and badge when their fields are absent, and falls back to description", () => {
|
|
67
|
+
expect(templateCard(bare, labels)).toEqual({
|
|
68
|
+
title: "Template B",
|
|
69
|
+
description: "Description B.",
|
|
70
|
+
link: { label: "View template", href: "/templates/b" },
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("omits the eyebrow when every category slug is unknown", () => {
|
|
75
|
+
const card = templateCard(
|
|
76
|
+
{ slug: "c", frontmatter: { ...bare.frontmatter, categories: ["nope"] } },
|
|
77
|
+
labels,
|
|
78
|
+
);
|
|
79
|
+
expect(card).not.toHaveProperty("eyebrow");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("shows at most three category names, then +N more", () => {
|
|
83
|
+
const card = templateCard(
|
|
84
|
+
{
|
|
85
|
+
slug: "d",
|
|
86
|
+
frontmatter: {
|
|
87
|
+
...bare.frontmatter,
|
|
88
|
+
categories: ["accounting", "legal", "consulting", "hvac", "roofing"],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
labels,
|
|
92
|
+
);
|
|
93
|
+
expect(card.eyebrow).toBe("Accounting · Legal · Consulting · +2 more");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("templateActionLabel", () => {
|
|
98
|
+
it("is Start free only for a price of exactly 0", () => {
|
|
99
|
+
expect(templateActionLabel(0, labels)).toBe("Start free");
|
|
100
|
+
expect(templateActionLabel(undefined, labels)).toBe("Get it");
|
|
101
|
+
expect(templateActionLabel(5, labels)).toBe("Get it");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("templateDetailSections", () => {
|
|
106
|
+
it("builds a hero and a What's included section", () => {
|
|
107
|
+
const [hero, included] = templateDetailSections(full.frontmatter, labels);
|
|
108
|
+
expect(hero).toMatchObject({
|
|
109
|
+
type: "hero",
|
|
110
|
+
data: {
|
|
111
|
+
headline: "Template A",
|
|
112
|
+
subheadline: "Summary A.",
|
|
113
|
+
eyebrow: "Accounting · Legal",
|
|
114
|
+
image: { src: "/img/a.png", alt: "Template A" },
|
|
115
|
+
primaryCta: { label: "Start free", href: "/contact" },
|
|
116
|
+
secondaryCta: {
|
|
117
|
+
label: "View demo",
|
|
118
|
+
href: "https://a.salessmyth.com",
|
|
119
|
+
variant: "outline",
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
expect(included).toEqual({
|
|
124
|
+
type: "items",
|
|
125
|
+
id: "template-included",
|
|
126
|
+
data: {
|
|
127
|
+
headline: "What's included",
|
|
128
|
+
variant: "minimal",
|
|
129
|
+
items: [
|
|
130
|
+
{ title: "Base site", description: "Pages and structured data." },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("omits the demo link, image and eyebrow when absent", () => {
|
|
137
|
+
const [hero] = templateDetailSections(bare.frontmatter, labels);
|
|
138
|
+
const data = (hero as { data: Record<string, unknown> }).data;
|
|
139
|
+
expect(data.primaryCta).toEqual({
|
|
140
|
+
label: "Get it",
|
|
141
|
+
href: "https://buy.example.com/b",
|
|
142
|
+
});
|
|
143
|
+
expect(data).not.toHaveProperty("secondaryCta");
|
|
144
|
+
expect(data).not.toHaveProperty("image");
|
|
145
|
+
expect(data).not.toHaveProperty("eyebrow");
|
|
146
|
+
expect(data.subheadline).toBe("Description B.");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it.each([
|
|
150
|
+
["full", full.frontmatter],
|
|
151
|
+
["bare", bare.frontmatter],
|
|
152
|
+
])("the %s result parses PageSectionsSchema", (_label, frontmatter) => {
|
|
153
|
+
expect(
|
|
154
|
+
PageSectionsSchema.safeParse(templateDetailSections(frontmatter, labels))
|
|
155
|
+
.success,
|
|
156
|
+
).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth-secret.ts — `resolveSecret()`, moved verbatim out of `auth.ts`
|
|
3
|
+
* (#8556, spec docs/specs/9965-sweep-a-trust-or-authz-spec.md D7) so a
|
|
4
|
+
* consumer that only needs the secret does not load Better Auth, which is
|
|
5
|
+
* ESM-only. `auth.ts` re-exports it, so every existing import is unchanged.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the secret used to sign session cookies AND — since #5962 § D9 — the
|
|
10
|
+
* email-verification JWTs.
|
|
11
|
+
*
|
|
12
|
+
* THROWS in production when BETTER_AUTH_SECRET is unset (§ D16). Better Auth's
|
|
13
|
+
* own `validateSecret` throws only when the secret equals ITS default; this
|
|
14
|
+
* repo's literal is a different string, so it cleared that check and produced
|
|
15
|
+
* nothing but a low-entropy warning — while sitting in a repository whose
|
|
16
|
+
* `oss`-tier tree ships publicly. Anyone who can read this file could forge
|
|
17
|
+
* both a session cookie and a verification token on any deployment that
|
|
18
|
+
* omitted the variable.
|
|
19
|
+
*
|
|
20
|
+
* This changes boot behaviour: a production deployment with no
|
|
21
|
+
* BETTER_AUTH_SECRET boots today and does not after this. That is the correct
|
|
22
|
+
* failure — a site signing sessions with a published string is not usefully
|
|
23
|
+
* "up" — and `apps/web/.env.example` has always documented the variable as
|
|
24
|
+
* REQUIRED in production. The change makes the file honest rather than adding
|
|
25
|
+
* a requirement.
|
|
26
|
+
*
|
|
27
|
+
* Exported (#8146 D5) so the hub's sign-in continuation seals its ref under the
|
|
28
|
+
* same resolved secret instead of re-reading the variable: the second consumer
|
|
29
|
+
* after `betterAuth({ secret })`.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveSecret(): string {
|
|
32
|
+
const configured = process.env.BETTER_AUTH_SECRET;
|
|
33
|
+
if (configured) return configured;
|
|
34
|
+
|
|
35
|
+
// SERVING, not BUILDING. `next build` collects page data with
|
|
36
|
+
// NODE_ENV=production, and that evaluates `app/api/auth/[...all]/route.ts`,
|
|
37
|
+
// which resolves the lazy `auth` proxy — so an unqualified production throw
|
|
38
|
+
// fails the BUILD of any tree that has not set the variable. That is much
|
|
39
|
+
// broader than the risk: a build produces an artifact, it does not serve a
|
|
40
|
+
// session, and it would break CI, preview builds, and `pnpm build` on a
|
|
41
|
+
// freshly scaffolded site that has not configured auth at all.
|
|
42
|
+
//
|
|
43
|
+
// Next sets NEXT_PHASE to PHASE_PRODUCTION_BUILD ("phase-production-build")
|
|
44
|
+
// for the duration of the build, so excluding that phase keeps the guard
|
|
45
|
+
// exactly where it was aimed: a production process that is about to answer
|
|
46
|
+
// requests. Such a process still throws on the first access to `auth`, which
|
|
47
|
+
// is the intended failure — a site signing sessions with a string published
|
|
48
|
+
// in this repository is not usefully "up".
|
|
49
|
+
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
|
|
50
|
+
if (process.env.NODE_ENV === "production" && !isBuildPhase) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
"BETTER_AUTH_SECRET is required in production: it signs session cookies and email-verification tokens, and the development fallback is published in this repository.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return "ailk-dev-fallback-secret-published-in-this-repo-never-use-in-production";
|
|
57
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The spoke edge's one bot check (#9889, #9255, spec
|
|
3
|
+
* docs/specs/9255-an-edge-bot-challenge-in-spec.md).
|
|
4
|
+
*
|
|
5
|
+
* Vercel BotID's verdict on a public write, asked on this site's own server
|
|
6
|
+
* before the hub is called: by the relay for its checked pairs (`relay.ts`'s
|
|
7
|
+
* `BOT_CHECKED_ROUTES`) and by each public lead, flow and checkout Server
|
|
8
|
+
* Action immediately before its hub call. MCP tools and key-bearing API calls
|
|
9
|
+
* never pass through a site, so they are never challenged.
|
|
10
|
+
*
|
|
11
|
+
* It fails closed (#9889 D3): only `isBot === false` passes. A bot is `bot`;
|
|
12
|
+
* a check that throws, answers no boolean or takes over 5 seconds is
|
|
13
|
+
* `unavailable`. It runs only on Vercel (`VERCEL=1`); `AILK_BOT_CHECK=off`
|
|
14
|
+
* turns every check off and `AILK_BOOKING_BOT_CHECK=off` the booking's alone.
|
|
15
|
+
*
|
|
16
|
+
* NO SECRET IS LOGGED. A refusal logs `{ path, outcome }` and, for a failed
|
|
17
|
+
* check, the error's name — never its message.
|
|
18
|
+
*/
|
|
19
|
+
import { checkBotId } from "botid/server";
|
|
20
|
+
|
|
21
|
+
export type BotCheckScope = "write" | "booking";
|
|
22
|
+
export type BotCheckOutcome = "pass" | "bot" | "unavailable";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* BotID's browser `protect` list: every same-origin POST. A Server Action
|
|
26
|
+
* posts to the URL of the page it was submitted from (the footer's newsletter
|
|
27
|
+
* signup is on every page), and a checked request the client did not protect
|
|
28
|
+
* carries no challenge header and is refused as a bot.
|
|
29
|
+
*/
|
|
30
|
+
export const BOT_CLIENT_PROTECT = Object.freeze([
|
|
31
|
+
Object.freeze({ path: "/*", method: "POST" }),
|
|
32
|
+
]) as { path: string; method: string }[];
|
|
33
|
+
|
|
34
|
+
/** The longest a check waits for a verdict; the library's own fetch has no bound. */
|
|
35
|
+
const BOT_CHECK_TIMEOUT_MS = 5_000;
|
|
36
|
+
|
|
37
|
+
/** Unset, empty or any other value leaves a check on, so a typo fails toward protection. */
|
|
38
|
+
function isOff(value: string | undefined): boolean {
|
|
39
|
+
return value?.trim().toLowerCase() === "off";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function switchedOff(scope: BotCheckScope): boolean {
|
|
43
|
+
return (
|
|
44
|
+
isOff(process.env.AILK_BOT_CHECK) ||
|
|
45
|
+
(scope === "booking" && isOff(process.env.AILK_BOOKING_BOT_CHECK))
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True when the check runs for `scope`. Read on every call, never cached. */
|
|
50
|
+
export function botCheckOn(scope: BotCheckScope): boolean {
|
|
51
|
+
return process.env.VERCEL === "1" && !switchedOff(scope);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let warnedBotCheckInactive = false;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Called when the check is off. Off Vercel it cannot run (#9889 D2): a
|
|
58
|
+
* production site whose switch is not `off` logs that once per process, so an
|
|
59
|
+
* owner who believes it is on can find out. It never refuses.
|
|
60
|
+
*/
|
|
61
|
+
function warnBotCheckInactiveOnce(scope: BotCheckScope): void {
|
|
62
|
+
if (warnedBotCheckInactive || process.env.NODE_ENV !== "production") return;
|
|
63
|
+
if (switchedOff(scope)) return;
|
|
64
|
+
warnedBotCheckInactive = true;
|
|
65
|
+
console.warn("[relay] booking bot check inactive", {
|
|
66
|
+
outcome: "not-on-vercel",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function namedError(name: string): Error {
|
|
71
|
+
const err = new Error(name);
|
|
72
|
+
err.name = name;
|
|
73
|
+
return err;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* BotID's verdict on this request: `true` for a bot, `false` for a human.
|
|
78
|
+
* Throws `TimeoutError` past `BOT_CHECK_TIMEOUT_MS` and `InvalidVerdictError`
|
|
79
|
+
* when `isBot` is not a boolean, as `checkBotId` returns for an error answer
|
|
80
|
+
* from Vercel.
|
|
81
|
+
*/
|
|
82
|
+
async function isBotVerdict(): Promise<boolean> {
|
|
83
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
84
|
+
try {
|
|
85
|
+
const verdict = await Promise.race([
|
|
86
|
+
checkBotId(),
|
|
87
|
+
new Promise<never>((_resolve, reject) => {
|
|
88
|
+
timer = setTimeout(
|
|
89
|
+
() => reject(namedError("TimeoutError")),
|
|
90
|
+
BOT_CHECK_TIMEOUT_MS,
|
|
91
|
+
);
|
|
92
|
+
}),
|
|
93
|
+
]);
|
|
94
|
+
const isBot = (verdict as { isBot?: unknown } | undefined)?.isBot;
|
|
95
|
+
if (typeof isBot !== "boolean") throw namedError("InvalidVerdictError");
|
|
96
|
+
return isBot;
|
|
97
|
+
} finally {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The check for one write. `source` names the log prefix and `target` the
|
|
104
|
+
* relayed path or the action's name — never a field value. Never throws.
|
|
105
|
+
*/
|
|
106
|
+
export async function runBotCheck({
|
|
107
|
+
source,
|
|
108
|
+
target,
|
|
109
|
+
scope,
|
|
110
|
+
}: {
|
|
111
|
+
source: string;
|
|
112
|
+
target: string;
|
|
113
|
+
scope: BotCheckScope;
|
|
114
|
+
}): Promise<BotCheckOutcome> {
|
|
115
|
+
if (!botCheckOn(scope)) {
|
|
116
|
+
warnBotCheckInactiveOnce(scope);
|
|
117
|
+
return "pass";
|
|
118
|
+
}
|
|
119
|
+
let isBot: boolean;
|
|
120
|
+
try {
|
|
121
|
+
isBot = await isBotVerdict();
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error(`[${source}] bot check failed`, {
|
|
124
|
+
path: target,
|
|
125
|
+
outcome: "bot-check-failed",
|
|
126
|
+
error: err instanceof Error ? err.name : "unknown",
|
|
127
|
+
});
|
|
128
|
+
return "unavailable";
|
|
129
|
+
}
|
|
130
|
+
if (isBot) {
|
|
131
|
+
console.warn(`[${source}] bot refused`, {
|
|
132
|
+
path: target,
|
|
133
|
+
outcome: "bot-detected",
|
|
134
|
+
});
|
|
135
|
+
return "bot";
|
|
136
|
+
}
|
|
137
|
+
return "pass";
|
|
138
|
+
}
|
|
@@ -10,9 +10,43 @@
|
|
|
10
10
|
*
|
|
11
11
|
* httpOnly, `Secure` in production, `SameSite=Lax`, path `/`, one year — a
|
|
12
12
|
* Server Action can read and mint it; client script never sees its value.
|
|
13
|
+
*
|
|
14
|
+
* The cookie is `<uuid>.<sig>`, signed by this site (#8556, spec
|
|
15
|
+
* docs/specs/9965-sweep-a-trust-or-authz-spec.md D7): `sig` is the base64url
|
|
16
|
+
* HMAC-SHA256 of the UUID under a subkey derived from the site's own secret
|
|
17
|
+
* with a fixed label, so that secret is never used raw for a second purpose.
|
|
18
|
+
* A read returns the bare UUID only when the signature verifies, so a visitor
|
|
19
|
+
* id the browser made up is never forwarded as `x-ailk-consent-visitor`.
|
|
20
|
+
*
|
|
21
|
+
* The secret is one the site already holds, taken in this fixed order
|
|
22
|
+
* (operator decision on #9965, option (b)):
|
|
23
|
+
*
|
|
24
|
+
* 1. `BETTER_AUTH_SECRET`, via `resolveSecret()` — a hub, or any site that
|
|
25
|
+
* builds Better Auth;
|
|
26
|
+
* 2. `AILK_HUB_SESSION_SECRET` — a spoke that signs in through its hub;
|
|
27
|
+
* 3. `AILK_SPOKE_KEY` — any other spoke;
|
|
28
|
+
*
|
|
29
|
+
* each of 2 and 3 only at 32 characters or more once trimmed (the floor
|
|
30
|
+
* `hub-session/config.ts` sets; a minted spoke key is longer), so a short
|
|
31
|
+
* value a visitor could brute-force from their own cookie is never a key;
|
|
32
|
+
* 4. otherwise `resolveSecret()` again, which is the development fallback
|
|
33
|
+
* outside production and THROWS in a production process.
|
|
34
|
+
*
|
|
35
|
+
* So a production spoke, which create-ailk deliberately gives no
|
|
36
|
+
* `BETTER_AUTH_SECRET`, still signs and verifies its own ids. With none of
|
|
37
|
+
* the three set a read is `undefined` and a mint throws, so nothing unsigned
|
|
38
|
+
* is ever written or forwarded (fail closed). A verifier recomputes the
|
|
39
|
+
* signature under its own secret, so it accepts only what its own site signed.
|
|
13
40
|
*/
|
|
14
41
|
|
|
42
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
43
|
+
|
|
44
|
+
import { resolveSecret } from "./auth-secret";
|
|
45
|
+
|
|
15
46
|
const CONSENT_VISITOR_COOKIE_NAME = "ailk-vid";
|
|
47
|
+
const SIGNING_LABEL = "ailk-consent-visitor-v1";
|
|
48
|
+
/** The shortest spoke-held secret this module signs with. */
|
|
49
|
+
const MIN_FALLBACK_SECRET_LENGTH = 32;
|
|
16
50
|
const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;
|
|
17
51
|
|
|
18
52
|
const V4_UUID =
|
|
@@ -23,6 +57,42 @@ export function isValidConsentVisitorId(value: string): boolean {
|
|
|
23
57
|
return V4_UUID.test(value);
|
|
24
58
|
}
|
|
25
59
|
|
|
60
|
+
/** The site's own signing secret, in the order the header documents. */
|
|
61
|
+
function signingSecret(): string {
|
|
62
|
+
if (process.env.BETTER_AUTH_SECRET) return resolveSecret();
|
|
63
|
+
const fallback = [process.env.AILK_HUB_SESSION_SECRET, process.env.AILK_SPOKE_KEY]
|
|
64
|
+
.map((value) => value?.trim() ?? "")
|
|
65
|
+
.find((value) => value.length >= MIN_FALLBACK_SECRET_LENGTH);
|
|
66
|
+
return fallback ?? resolveSecret();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The base64url signature of `id`. Throws when no secret is set in production. */
|
|
70
|
+
function sign(id: string): string {
|
|
71
|
+
const subkey = createHmac("sha256", signingSecret()).update(SIGNING_LABEL).digest();
|
|
72
|
+
return createHmac("sha256", subkey).update(id).digest("base64url");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The UUID a signed cookie value carries, or `undefined` for a bare UUID, a
|
|
77
|
+
* bad or wrong-length signature, or a secret that cannot be resolved.
|
|
78
|
+
*/
|
|
79
|
+
function verifiedId(value: string): string | undefined {
|
|
80
|
+
const dot = value.indexOf(".");
|
|
81
|
+
if (dot === -1) return undefined;
|
|
82
|
+
const id = value.slice(0, dot);
|
|
83
|
+
if (!isValidConsentVisitorId(id)) return undefined;
|
|
84
|
+
let expected: Buffer;
|
|
85
|
+
try {
|
|
86
|
+
expected = Buffer.from(sign(id));
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const presented = Buffer.from(value.slice(dot + 1));
|
|
91
|
+
// timingSafeEqual throws on unequal lengths, so that is checked first.
|
|
92
|
+
if (presented.length !== expected.length) return undefined;
|
|
93
|
+
return timingSafeEqual(presented, expected) ? id : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
26
96
|
/** The subset of Next's `cookies()` jar a read needs. */
|
|
27
97
|
export interface ConsentVisitorCookieReader {
|
|
28
98
|
get(name: string): { value: string } | undefined;
|
|
@@ -50,16 +120,21 @@ export interface ConsentVisitorCookieWriter extends ConsentVisitorCookieReader {
|
|
|
50
120
|
set(name: string, value: string, options: ConsentVisitorCookieOptions): void;
|
|
51
121
|
}
|
|
52
122
|
|
|
53
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* The existing visitor id, or `undefined` with none set or a value whose
|
|
125
|
+
* signature does not verify.
|
|
126
|
+
*/
|
|
54
127
|
export function readConsentVisitor(
|
|
55
128
|
cookies: ConsentVisitorCookieReader,
|
|
56
129
|
): string | undefined {
|
|
57
130
|
const value = cookies.get(CONSENT_VISITOR_COOKIE_NAME)?.value;
|
|
58
|
-
return value !== undefined
|
|
131
|
+
return value !== undefined ? verifiedId(value) : undefined;
|
|
59
132
|
}
|
|
60
133
|
|
|
61
134
|
/**
|
|
62
|
-
* The visitor id, minting and setting a fresh one when absent or
|
|
135
|
+
* The visitor id, minting and setting a fresh signed one when absent or when
|
|
136
|
+
* the stored value does not verify (a cookie minted before signing included).
|
|
137
|
+
* Throws, setting nothing, when a production site holds none of the secrets.
|
|
63
138
|
* Server Actions only — Next refuses a cookie write outside one (the same
|
|
64
139
|
* posture `hub-session/reseal.ts` documents).
|
|
65
140
|
*/
|
|
@@ -67,7 +142,7 @@ export function ensureConsentVisitor(cookies: ConsentVisitorCookieWriter): strin
|
|
|
67
142
|
const existing = readConsentVisitor(cookies);
|
|
68
143
|
if (existing !== undefined) return existing;
|
|
69
144
|
const id = crypto.randomUUID();
|
|
70
|
-
cookies.set(CONSENT_VISITOR_COOKIE_NAME, id
|
|
145
|
+
cookies.set(CONSENT_VISITOR_COOKIE_NAME, `${id}.${sign(id)}`, {
|
|
71
146
|
httpOnly: true,
|
|
72
147
|
secure: process.env.NODE_ENV === "production",
|
|
73
148
|
sameSite: "lax",
|
|
@@ -85,7 +160,7 @@ export function consentVisitorHeader(id: string): Record<string, string> {
|
|
|
85
160
|
/**
|
|
86
161
|
* The visitor id out of a raw `Cookie:` header string (the relay's own
|
|
87
162
|
* `Request`, which carries no Next `cookies()` jar). `undefined` with no
|
|
88
|
-
* header, no matching cookie, or a value
|
|
163
|
+
* header, no matching cookie, or a value whose signature does not verify.
|
|
89
164
|
*/
|
|
90
165
|
export function readConsentVisitorFromCookieHeader(
|
|
91
166
|
header: string | null | undefined,
|
|
@@ -96,8 +171,7 @@ export function readConsentVisitorFromCookieHeader(
|
|
|
96
171
|
if (eq === -1) continue;
|
|
97
172
|
const name = part.slice(0, eq).trim();
|
|
98
173
|
if (name !== CONSENT_VISITOR_COOKIE_NAME) continue;
|
|
99
|
-
|
|
100
|
-
return isValidConsentVisitorId(value) ? value : undefined;
|
|
174
|
+
return verifiedId(part.slice(eq + 1).trim());
|
|
101
175
|
}
|
|
102
176
|
return undefined;
|
|
103
177
|
}
|
|
@@ -42,11 +42,14 @@ export const DECK_ROUTES = [
|
|
|
42
42
|
"/faq",
|
|
43
43
|
"/launch-service",
|
|
44
44
|
"/pricing",
|
|
45
|
+
"/pricing/:slug",
|
|
45
46
|
"/privacy",
|
|
46
47
|
"/projects",
|
|
47
48
|
"/projects/:slug",
|
|
48
49
|
"/purchase-policy",
|
|
49
50
|
"/reports",
|
|
51
|
+
"/templates",
|
|
52
|
+
"/templates/:slug",
|
|
50
53
|
"/terms",
|
|
51
54
|
"/thanks/:slug",
|
|
52
55
|
"/working-agreement",
|
|
@@ -65,7 +68,7 @@ function routeSegments(route: string): string[] {
|
|
|
65
68
|
return route.split("/").filter((segment) => segment.length > 0);
|
|
66
69
|
}
|
|
67
70
|
|
|
68
|
-
function matchesRoute(routeDef: string, pathSegments: readonly string[]): boolean {
|
|
71
|
+
export function matchesRoute(routeDef: string, pathSegments: readonly string[]): boolean {
|
|
69
72
|
const defSegments = routeSegments(routeDef);
|
|
70
73
|
if (defSegments.length !== pathSegments.length) return false;
|
|
71
74
|
return defSegments.every(
|
|
@@ -74,7 +77,7 @@ function matchesRoute(routeDef: string, pathSegments: readonly string[]): boolea
|
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
/** The type and slug a matched routes-map row resolves to for this request. */
|
|
77
|
-
function resolveTypeAndSlug(
|
|
80
|
+
export function resolveTypeAndSlug(
|
|
78
81
|
row: RouteRow,
|
|
79
82
|
routeDef: string,
|
|
80
83
|
pathSegments: readonly string[],
|
|
@@ -94,7 +97,7 @@ function resolveTypeAndSlug(
|
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
/** Whether a routes-map row at this exact route is always scaffolded (never absent from a site). */
|
|
97
|
-
function isAlwaysScaffolded(path: string): boolean {
|
|
100
|
+
export function isAlwaysScaffolded(path: string): boolean {
|
|
98
101
|
const row = ROUTES.find((candidate) => candidate.route === path);
|
|
99
102
|
return row !== undefined && row.gateable !== true && row.module === "marketing";
|
|
100
103
|
}
|