create-ailk 0.27.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 +84 -7
- 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 +23 -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 +105 -9
- 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 +38 -4
- 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 +5 -2
- package/templates/apps/api/.env.example +9 -5
- package/templates/apps/api/CLAUDE.md +14 -9
- 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/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +25 -11
- package/templates/apps/web/CLAUDE.md +45 -4
- 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]/(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 +157 -139
- 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 +204 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
- 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 +202 -10
- 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/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 +31 -106
- 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/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 +86 -30
- package/templates/apps/web/middleware.ts +171 -11
- package/templates/apps/web/next.config.mjs +211 -2
- package/templates/apps/web/package.json +1 -1
- 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,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
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// docs-index.ts — the docs list `/sitemap.xml` and `/llms.txt` read (#10041 D8).
|
|
2
|
+
//
|
|
3
|
+
// A base file: it reads the docs directory itself rather than `docsSource`,
|
|
4
|
+
// which only a docs-blog site carries, so the sitemap and llms routes stay in
|
|
5
|
+
// the Free template and list the docs on any site that has them. It imports
|
|
6
|
+
// no docs-blog owned file (a relative import of one would drop it, and every
|
|
7
|
+
// file importing it, from the Free template).
|
|
8
|
+
//
|
|
9
|
+
// Default locale only: the docs exist in one locale (`source.config.ts` reads
|
|
10
|
+
// `content/en/docs`).
|
|
11
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { parse as parseYaml } from "yaml";
|
|
15
|
+
|
|
16
|
+
import { DOCS_PATH } from "./docs-path";
|
|
17
|
+
|
|
18
|
+
export interface DocsIndexPage {
|
|
19
|
+
url: string;
|
|
20
|
+
title: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The two content-adapter types the docs route reads under the database adapter. */
|
|
25
|
+
const DOCS_TYPES = new Set(["DocsList", "DocsDetail"]);
|
|
26
|
+
|
|
27
|
+
export function isDocsType(type: string): boolean {
|
|
28
|
+
return DOCS_TYPES.has(type);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** An adapter docs row's URL: the index at `DOCS_PATH`, a doc under it. */
|
|
32
|
+
export function adapterDocsUrl(type: string, slug: string): string {
|
|
33
|
+
return type === "DocsList" || slug === "index"
|
|
34
|
+
? DOCS_PATH
|
|
35
|
+
: `${DOCS_PATH}/${slug}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function frontmatterOf(raw: string): Record<string, unknown> | null {
|
|
39
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
|
|
40
|
+
if (!match) return null;
|
|
41
|
+
const parsed: unknown = parseYaml(match[1]!);
|
|
42
|
+
return parsed && typeof parsed === "object"
|
|
43
|
+
? (parsed as Record<string, unknown>)
|
|
44
|
+
: null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fumadocs' slug rule: the path segments without the extension, a trailing
|
|
49
|
+
* `index` dropped, a `(group)` segment dropped.
|
|
50
|
+
*/
|
|
51
|
+
function urlFor(segments: string[]): string {
|
|
52
|
+
const slugs = segments.filter((s) => !/^\(.+\)$/.test(s));
|
|
53
|
+
if (slugs.at(-1) === "index") slugs.pop();
|
|
54
|
+
return slugs.length === 0 ? DOCS_PATH : `${DOCS_PATH}/${slugs.join("/")}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function walk(dir: string, segments: string[], out: DocsIndexPage[]): void {
|
|
58
|
+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
|
|
59
|
+
a.name.localeCompare(b.name),
|
|
60
|
+
);
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
walk(join(dir, entry.name), [...segments, entry.name], out);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!entry.isFile() || !entry.name.endsWith(".mdx")) continue;
|
|
67
|
+
const data = frontmatterOf(readFileSync(join(dir, entry.name), "utf8"));
|
|
68
|
+
if (!data || typeof data.title !== "string" || data.title === "") continue;
|
|
69
|
+
out.push({
|
|
70
|
+
url: urlFor([...segments, entry.name.slice(0, -".mdx".length)]),
|
|
71
|
+
title: data.title,
|
|
72
|
+
...(typeof data.description === "string" && data.description !== ""
|
|
73
|
+
? { description: data.description }
|
|
74
|
+
: {}),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Every titled `*.mdx` page under `dir` (default: this app's
|
|
81
|
+
* `content/<default locale>/docs`), at the docs' public path. An absent
|
|
82
|
+
* directory — every Free site — is `[]`; a page with no `title` is skipped.
|
|
83
|
+
*/
|
|
84
|
+
export function listDocsPages(
|
|
85
|
+
dir: string = join(
|
|
86
|
+
process.cwd(),
|
|
87
|
+
"content",
|
|
88
|
+
process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? "en",
|
|
89
|
+
"docs",
|
|
90
|
+
),
|
|
91
|
+
): DocsIndexPage[] {
|
|
92
|
+
const pages: DocsIndexPage[] = [];
|
|
93
|
+
try {
|
|
94
|
+
walk(dir, [], pages);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
return pages;
|
|
100
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// docs-path.ts — where the docs live (#10041 D2).
|
|
2
|
+
//
|
|
3
|
+
// A site sets `docsPath` in `content/_site.mdx` (e.g. `/working-theory/docs`);
|
|
4
|
+
// `next.config.mjs` parses it at build and bakes it into
|
|
5
|
+
// `NEXT_PUBLIC_AILK_DOCS_PATH`. The docs route itself never moves — it stays
|
|
6
|
+
// at `app/[locale]/docs/` (`DOCS_ROUTE`) — and the middleware maps the public
|
|
7
|
+
// path onto it. Every surface that names a docs URL reads `DOCS_PATH` here.
|
|
8
|
+
//
|
|
9
|
+
// Edge-safe and client-safe: this file imports nothing at runtime (the
|
|
10
|
+
// `NavItem` import is type-only), so the middleware and the client signed-in
|
|
11
|
+
// nav can both read it. It is a base file: it must never import a docs-blog
|
|
12
|
+
// owned file, or it would leave the Free template.
|
|
13
|
+
import type { NavItem } from "@working-theory/ui";
|
|
14
|
+
|
|
15
|
+
/** The internal docs route — the directory under `app/[locale]/`. Always `/docs`. */
|
|
16
|
+
export const DOCS_ROUTE = "/docs";
|
|
17
|
+
|
|
18
|
+
/** The docs' public base: the site's `docsPath`, or `/docs` when it sets none. */
|
|
19
|
+
export const DOCS_PATH: string =
|
|
20
|
+
process.env.NEXT_PUBLIC_AILK_DOCS_PATH || DOCS_ROUTE;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `href` at the internal docs route — `/docs`, or starting `/docs/`, `/docs?`
|
|
24
|
+
* or `/docs#` — with that prefix replaced by `DOCS_PATH`. Anything else
|
|
25
|
+
* (absolute URLs, `/docsets`, a locale-prefixed path) is returned unchanged.
|
|
26
|
+
* The identity on a site with no `docsPath`.
|
|
27
|
+
*/
|
|
28
|
+
export function toPublicDocsHref(href: string): string {
|
|
29
|
+
if (DOCS_PATH === DOCS_ROUTE) return href;
|
|
30
|
+
const rest = href.slice(DOCS_ROUTE.length);
|
|
31
|
+
if (href.startsWith(DOCS_ROUTE) && (rest === "" || /^[/?#]/.test(rest))) {
|
|
32
|
+
return DOCS_PATH + rest;
|
|
33
|
+
}
|
|
34
|
+
return href;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A locale-less pathname at or under `DOCS_PATH`, mapped onto the internal
|
|
39
|
+
* route (`/docs` + the rest); `null` for anything else, and always `null` on a
|
|
40
|
+
* site with no `docsPath`.
|
|
41
|
+
*/
|
|
42
|
+
export function toInternalDocsPath(bare: string): string | null {
|
|
43
|
+
if (DOCS_PATH === DOCS_ROUTE) return null;
|
|
44
|
+
if (bare === DOCS_PATH) return DOCS_ROUTE;
|
|
45
|
+
if (bare.startsWith(`${DOCS_PATH}/`)) {
|
|
46
|
+
return DOCS_ROUTE + bare.slice(DOCS_PATH.length);
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type NavFlyout = NonNullable<NavItem["flyout"]>;
|
|
52
|
+
|
|
53
|
+
function mapPath<T extends { path: string }>(entry: T): T {
|
|
54
|
+
return { ...entry, path: toPublicDocsHref(entry.path) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mapFlyout(flyout: NavFlyout): NavFlyout {
|
|
58
|
+
return {
|
|
59
|
+
...flyout,
|
|
60
|
+
features: flyout.features.map(mapPath),
|
|
61
|
+
...(flyout.groups
|
|
62
|
+
? {
|
|
63
|
+
groups: flyout.groups.map((group) => ({
|
|
64
|
+
...group,
|
|
65
|
+
items: group.items.map(mapPath),
|
|
66
|
+
})),
|
|
67
|
+
}
|
|
68
|
+
: {}),
|
|
69
|
+
actions: flyout.actions.map(mapPath),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Copies of the nav items with every docs path mapped onto `DOCS_PATH` — the
|
|
75
|
+
* item's own `path` and each flyout feature, group item and action. Every
|
|
76
|
+
* other field (icons are functions) is carried by reference.
|
|
77
|
+
*/
|
|
78
|
+
export function withPublicDocsPaths(items: NavItem[]): NavItem[] {
|
|
79
|
+
return items.map((item) => ({
|
|
80
|
+
...item,
|
|
81
|
+
...(item.path !== undefined ? { path: toPublicDocsHref(item.path) } : {}),
|
|
82
|
+
...(item.flyout ? { flyout: mapFlyout(item.flyout) } : {}),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Copies of the footer columns with every item's docs path mapped onto `DOCS_PATH`. */
|
|
87
|
+
export function withPublicDocsColumns<C extends { items: { path: string }[] }>(
|
|
88
|
+
columns: C[],
|
|
89
|
+
): C[] {
|
|
90
|
+
return columns.map((column) => ({
|
|
91
|
+
...column,
|
|
92
|
+
items: column.items.map(mapPath),
|
|
93
|
+
}));
|
|
94
|
+
}
|