create-ailk 0.27.0 → 0.28.1
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/chrome-readers.d.ts +21 -5
- package/dist/lib/chrome-readers.js +128 -20
- package/dist/lib/declared-env.d.ts +6 -0
- package/dist/lib/declared-env.js +2 -0
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/import-graph.d.ts +16 -0
- package/dist/lib/import-graph.js +53 -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/template-record.d.ts +13 -4
- package/dist/template-record.js +8 -4
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +34 -2
- package/dist/update.js +395 -48
- 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 +224 -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 +237 -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 +279 -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/templates/tools/declared-env.ts +7 -0
- package/versions.json +48 -48
package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx
CHANGED
|
@@ -79,7 +79,7 @@ describe("TestimonialsPage — snapshot regression", () => {
|
|
|
79
79
|
});
|
|
80
80
|
const { container } = render(element);
|
|
81
81
|
expect(container.innerHTML).toMatchInlineSnapshot(
|
|
82
|
-
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Customer Testimonials","description":"What our customers say.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Review"}]},"aggregateRating":{"@context":"https://schema.org","@type":"AggregateRating","ratingValue":4.9,"reviewCount":0,"bestRating":5,"worstRating":1,"itemReviewed":{"@type":"SoftwareApplication"}}}</script><main id="main"><div><
|
|
82
|
+
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Customer Testimonials","description":"What our customers say.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Review"}]},"aggregateRating":{"@context":"https://schema.org","@type":"AggregateRating","ratingValue":4.9,"reviewCount":0,"bestRating":5,"worstRating":1,"itemReviewed":{"@type":"SoftwareApplication"}}}</script><main id="main"><div><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div data-testid="mdx-remote" data-source=""></div></article></div></div></div></main>"`,
|
|
83
83
|
);
|
|
84
84
|
});
|
|
85
85
|
});
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// Whitepaper detail page. Reads the Whitepaper MDX for the given slug, renders via
|
|
2
2
|
// @working-theory/page-renderer, and emits a JSON-LD <script>. Unknown slugs trigger 404.
|
|
3
3
|
import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getMdxSource as loadMdxSource,
|
|
6
|
+
renderPage,
|
|
7
|
+
renderPageMetadata,
|
|
8
|
+
} from "@working-theory/page-renderer";
|
|
5
9
|
import type { Metadata } from "next";
|
|
6
10
|
import type { ReactElement } from "react";
|
|
7
11
|
|
|
@@ -22,12 +26,29 @@ export default async function Page({
|
|
|
22
26
|
getMdxSource,
|
|
23
27
|
}: Props): Promise<ReactElement> {
|
|
24
28
|
const { locale, slug } = await params;
|
|
29
|
+
// A page that composes `sections[]` renders them (JSON-LD is built from
|
|
30
|
+
// the same sections); a prose page renders its MDX body — the rule
|
|
31
|
+
// /vs/[slug] applies (#10122).
|
|
32
|
+
const source = await (getMdxSource ?? loadMdxSource)(
|
|
33
|
+
"Whitepaper",
|
|
34
|
+
slug,
|
|
35
|
+
locale,
|
|
36
|
+
);
|
|
37
|
+
const composed =
|
|
38
|
+
source !== null &&
|
|
39
|
+
Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
|
|
40
|
+
((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) >
|
|
41
|
+
0;
|
|
25
42
|
return renderPage({
|
|
26
43
|
type: "Whitepaper",
|
|
27
44
|
slug,
|
|
28
45
|
locale,
|
|
29
|
-
|
|
30
|
-
|
|
46
|
+
...(composed
|
|
47
|
+
? {}
|
|
48
|
+
: {
|
|
49
|
+
bodyMode: "mdx-body" as const,
|
|
50
|
+
populatedDescriptionTestId: "whitepaper-description",
|
|
51
|
+
}),
|
|
31
52
|
...(getMdxSource !== undefined && { getMdxSource }),
|
|
32
53
|
});
|
|
33
54
|
}
|
package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx
CHANGED
|
@@ -62,7 +62,7 @@ describe("whitepapers/[slug] — snapshot regression", () => {
|
|
|
62
62
|
});
|
|
63
63
|
const { container } = render(element);
|
|
64
64
|
expect(container.innerHTML).toMatchInlineSnapshot(`
|
|
65
|
-
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products.","mainEntity":{"@context":"https://schema.org","@type":"Article","headline":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Whitepapers","item":"http://localhost:3000/en/whitepapers"},{"@type":"ListItem","position":3,"name":"AILK AEO Strategy 2026","item":"http://localhost:3000/en/whitepapers/ailk-aeo-strategy-2026"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><
|
|
65
|
+
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products.","mainEntity":{"@context":"https://schema.org","@type":"Article","headline":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Whitepapers","item":"http://localhost:3000/en/whitepapers"},{"@type":"ListItem","position":3,"name":"AILK AEO Strategy 2026","item":"http://localhost:3000/en/whitepapers/ailk-aeo-strategy-2026"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div class="ailk-prose max-w-none text-(--color-text-muted) text-body-sm leading-relaxed [&>*:first-child]:mt-0 [&_h2]:mt-block-loose [&_h2]:scroll-mt-24 [&_h2]:text-heading-3 [&_h2]:font-display [&_h2]:text-(--color-fg) [&_h3]:mt-block-loose [&_h3]:scroll-mt-24 [&_h3]:text-heading-4 [&_h3]:font-display [&_h3]:text-(--color-fg) [&_h4]:mt-block-tight [&_h4]:scroll-mt-24 [&_h4]:text-heading-4 [&_h4]:font-display [&_h4]:text-(--color-fg) [&_h5]:mt-block-tight [&_h5]:scroll-mt-24 [&_h5]:text-heading-4 [&_h5]:font-display [&_h5]:text-(--color-fg) [&_h6]:mt-block-tight [&_h6]:scroll-mt-24 [&_h6]:text-heading-4 [&_h6]:font-display [&_h6]:text-(--color-fg) [&_p]:mt-paragraph [&_strong]:font-semibold [&_strong]:text-(--color-fg) [&_em]:italic [&_ul]:mt-paragraph [&_ul]:list-disc [&_ul]:ps-6 [&_ol]:mt-paragraph [&_ol]:list-decimal [&_ol]:ps-6 [&_li]:mt-1 [&_li]:ps-1 [&_li]:marker:text-(--color-text-muted) [&_li>ul]:mt-1 [&_li>ol]:mt-1 [&_blockquote]:mt-paragraph [&_blockquote]:border-s-4 [&_blockquote]:border-(--color-border-strong) [&_blockquote]:ps-4 [&_blockquote]:italic [&_blockquote]:text-(--color-text-muted) [&_hr]:my-block-loose [&_hr]:border-(--color-border) [&_a]:text-(--color-accent) [&_a]:underline [&_a]:underline-offset-4 [&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-(--color-surface-secondary) [&_:not(pre)>code]:px-1.5 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:text-code [&_:not(pre)>code]:font-mono [&_:not(pre)>code]:text-(--color-fg) [&_pre]:mt-paragraph [&_pre]:overflow-auto [&_pre]:rounded-md [&_pre]:border-hairline [&_pre]:border-(--color-border) [&_pre]:bg-(--color-bg) [&_pre]:p-4 [&_pre]:font-mono [&_pre]:text-body-sm [&_img]:mt-paragraph [&_img]:h-auto [&_img]:max-w-full [&_img]:rounded-lg [&_table]:mt-paragraph [&_table]:w-full [&_table]:border-collapse [&_table]:text-body-sm [&_th]:border-b [&_th]:border-(--color-border) [&_th]:bg-(--color-surface-secondary) [&_th]:px-3 [&_th]:py-2 [&_th]:text-start [&_th]:font-semibold [&_th]:text-(--color-fg) [&_td]:border-b [&_td]:border-(--color-border) [&_td]:px-3 [&_td]:py-2 [&_kbd]:rounded [&_kbd]:border-hairline [&_kbd]:border-(--color-border) [&_kbd]:bg-(--color-surface-secondary) [&_kbd]:px-1.5 [&_kbd]:py-0.5 [&_kbd]:text-caption [&_kbd]:font-mono" data-testid="mdx-content"><div data-testid="mdx-rendered">## Executive Summary
|
|
66
66
|
|
|
67
67
|
Body content.</div></div></article></div></div></div></main>"
|
|
68
68
|
`);
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
* #9912 D3 (AC-1): a single `prompt=create` sends a visitor with no session to
|
|
19
19
|
* `/sign-up` with the same continuation; any other value gives `/sign-in`, a
|
|
20
20
|
* session ignores it, and it never enters the ref or the code request.
|
|
21
|
+
*
|
|
22
|
+
* #9966 D2 (AC-4): a person who has not said yes to the spoke is sent to the
|
|
23
|
+
* hub's consent page with a sealed ref; only that page's same-origin POST
|
|
24
|
+
* sends `consent: true` — the GET a spoke can trigger on page load never does.
|
|
21
25
|
*/
|
|
22
26
|
|
|
23
27
|
// --- Mocks set up BEFORE imports ---
|
|
@@ -40,8 +44,8 @@ jest.mock("@working-theory/api-client", () => ({
|
|
|
40
44
|
}));
|
|
41
45
|
|
|
42
46
|
import { resolveNextPath } from "../../../../../[locale]/(auth)/sign-in/next-path";
|
|
43
|
-
import { openContinuation } from "../../continuation";
|
|
44
|
-
import { GET } from "../route";
|
|
47
|
+
import { mintContinuation, openContinuation } from "../../continuation";
|
|
48
|
+
import { GET, POST } from "../route";
|
|
45
49
|
|
|
46
50
|
const HUB = "https://hub.example.com";
|
|
47
51
|
const CONTINUE = "/api/auth/hub/continue";
|
|
@@ -577,3 +581,201 @@ describe("the sign-up hint, prompt=create (#9912 D3)", () => {
|
|
|
577
581
|
expect(mockCreateCode.mock.calls[0]).toEqual(mockCreateCode.mock.calls[1]);
|
|
578
582
|
});
|
|
579
583
|
});
|
|
584
|
+
|
|
585
|
+
describe("the consent step (#9966 D2, AC-4)", () => {
|
|
586
|
+
const SESSION_COOKIE = "better-auth.session_token=tok";
|
|
587
|
+
const CONSENT_REQUIRED = {
|
|
588
|
+
ok: false,
|
|
589
|
+
status: 403,
|
|
590
|
+
body: {
|
|
591
|
+
ok: false,
|
|
592
|
+
error: { code: "consent_required", message: "Consent required" },
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
/** A same-origin form post of `ref` fields from the hub's consent page. */
|
|
597
|
+
function post(
|
|
598
|
+
refs: string[],
|
|
599
|
+
headers: Record<string, string | undefined> = {},
|
|
600
|
+
): Request {
|
|
601
|
+
const body = new URLSearchParams();
|
|
602
|
+
for (const ref of refs) body.append("ref", ref);
|
|
603
|
+
const sent: Record<string, string> = {
|
|
604
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
605
|
+
cookie: SESSION_COOKIE,
|
|
606
|
+
"sec-fetch-site": "same-origin",
|
|
607
|
+
origin: HUB,
|
|
608
|
+
};
|
|
609
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
610
|
+
if (value === undefined) delete sent[key];
|
|
611
|
+
else sent[key] = value;
|
|
612
|
+
}
|
|
613
|
+
return new Request(`${HUB}/api/auth/hub/authorize`, {
|
|
614
|
+
method: "POST",
|
|
615
|
+
headers: sent,
|
|
616
|
+
body: body.toString(),
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const ref = (search = query(), now?: number) =>
|
|
621
|
+
mintContinuation(search, mockSecret, now);
|
|
622
|
+
|
|
623
|
+
it("GET: a session the API answers consent_required goes to /account/connect with a sealed ref, and sends no consent", async () => {
|
|
624
|
+
mockGetSession.mockResolvedValue({ user: { id: "u1" } });
|
|
625
|
+
mockCreateCode.mockResolvedValue(CONSENT_REQUIRED);
|
|
626
|
+
|
|
627
|
+
const res = await GET(request(query(), { cookie: SESSION_COOKIE }));
|
|
628
|
+
|
|
629
|
+
expect(res.status).toBe(303);
|
|
630
|
+
const location = new URL(res.headers.get("location") ?? "", HUB);
|
|
631
|
+
expect(location.pathname).toBe("/account/connect");
|
|
632
|
+
const sealed = location.searchParams.get("ref") ?? "";
|
|
633
|
+
expect(openContinuation(sealed, mockSecret)).toBe(query());
|
|
634
|
+
expect(mockCreateCode).toHaveBeenCalledTimes(1);
|
|
635
|
+
const [body, init] = mockCreateCode.mock.calls[0] as [
|
|
636
|
+
Record<string, unknown>,
|
|
637
|
+
{ headers: Record<string, string> },
|
|
638
|
+
];
|
|
639
|
+
expect("consent" in body).toBe(false);
|
|
640
|
+
expect("x-internal-auth" in init.headers).toBe(false);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it("POST: a same-origin Continue mints with consent: true and 303s to the registered URI with code and state", async () => {
|
|
644
|
+
mockGetSession.mockResolvedValue({ user: { id: "u1" } });
|
|
645
|
+
mockCreateCode.mockResolvedValue({
|
|
646
|
+
ok: true,
|
|
647
|
+
body: { code: CODE, redirectUri: SPOKE_CALLBACK },
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
const res = await POST(post([ref()]));
|
|
651
|
+
|
|
652
|
+
expect(res.status).toBe(303);
|
|
653
|
+
expect(res.headers.get("location")).toBe(
|
|
654
|
+
`${SPOKE_CALLBACK}?code=${CODE}&state=${STATE}`,
|
|
655
|
+
);
|
|
656
|
+
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
|
|
657
|
+
expect(mockCreateCode).toHaveBeenCalledWith(
|
|
658
|
+
{
|
|
659
|
+
clientId: "spoke_abc",
|
|
660
|
+
redirectUri: SPOKE_CALLBACK,
|
|
661
|
+
codeChallenge: CHALLENGE,
|
|
662
|
+
consent: true,
|
|
663
|
+
},
|
|
664
|
+
{ headers: { cookie: SESSION_COOKIE, origin: HUB } },
|
|
665
|
+
);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("POST: carries x-internal-auth when the secret is set and the API is https:", async () => {
|
|
669
|
+
process.env = {
|
|
670
|
+
...process.env,
|
|
671
|
+
OSS_API_INTERNAL_SECRET: "internal-secret",
|
|
672
|
+
AILK_API_URL: "https://api.example.com",
|
|
673
|
+
};
|
|
674
|
+
delete process.env.AILK_SPOKE_KEY;
|
|
675
|
+
mockGetSession.mockResolvedValue({ user: { id: "u1" } });
|
|
676
|
+
mockCreateCode.mockResolvedValue({
|
|
677
|
+
ok: true,
|
|
678
|
+
body: { code: CODE, redirectUri: SPOKE_CALLBACK },
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
await POST(post([ref()]));
|
|
682
|
+
|
|
683
|
+
const [, init] = mockCreateCode.mock.calls[0] as [
|
|
684
|
+
unknown,
|
|
685
|
+
{ headers: Record<string, string> },
|
|
686
|
+
];
|
|
687
|
+
expect(init.headers["x-internal-auth"]).toBe("internal-secret");
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it("POST: withholds x-internal-auth from a plain-http API", async () => {
|
|
691
|
+
process.env = {
|
|
692
|
+
...process.env,
|
|
693
|
+
OSS_API_INTERNAL_SECRET: "internal-secret",
|
|
694
|
+
AILK_API_URL: "http://api.example.com",
|
|
695
|
+
};
|
|
696
|
+
mockGetSession.mockResolvedValue({ user: { id: "u1" } });
|
|
697
|
+
mockCreateCode.mockResolvedValue({
|
|
698
|
+
ok: true,
|
|
699
|
+
body: { code: CODE, redirectUri: SPOKE_CALLBACK },
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
await POST(post([ref()]));
|
|
703
|
+
|
|
704
|
+
const [, init] = mockCreateCode.mock.calls[0] as [
|
|
705
|
+
unknown,
|
|
706
|
+
{ headers: Record<string, string> },
|
|
707
|
+
];
|
|
708
|
+
expect("x-internal-auth" in init.headers).toBe(false);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it.each([
|
|
712
|
+
["no Sec-Fetch-Site", { "sec-fetch-site": undefined }],
|
|
713
|
+
["Sec-Fetch-Site cross-site", { "sec-fetch-site": "cross-site" }],
|
|
714
|
+
["Sec-Fetch-Site same-site", { "sec-fetch-site": "same-site" }],
|
|
715
|
+
["a foreign Origin", { origin: "https://spoke.example.com" }],
|
|
716
|
+
["no Origin", { origin: undefined }],
|
|
717
|
+
["Origin: null", { origin: "null" }],
|
|
718
|
+
])(
|
|
719
|
+
"POST: %s is 403 with no session read and no code",
|
|
720
|
+
async (_name, headers) => {
|
|
721
|
+
mockGetSession.mockResolvedValue({ user: { id: "u1" } });
|
|
722
|
+
|
|
723
|
+
const res = await POST(post([ref()], headers));
|
|
724
|
+
|
|
725
|
+
expect(res.status).toBe(403);
|
|
726
|
+
expect(res.headers.get("location")).toBeNull();
|
|
727
|
+
expect(await res.json()).toEqual({ error: "invalid_request" });
|
|
728
|
+
expect(mockGetSession).not.toHaveBeenCalled();
|
|
729
|
+
expect(mockCreateCode).not.toHaveBeenCalled();
|
|
730
|
+
},
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
it.each([
|
|
734
|
+
["a forged ref", () => ["x".repeat(80)]],
|
|
735
|
+
["an expired ref", () => [ref(query(), Date.now() - 3_600_000)]],
|
|
736
|
+
["two ref fields", () => [ref(), ref()]],
|
|
737
|
+
["no ref field", () => []],
|
|
738
|
+
[
|
|
739
|
+
"a ref sealing a query that fails the schema",
|
|
740
|
+
() => [ref(query({ code_challenge_method: "plain" }))],
|
|
741
|
+
],
|
|
742
|
+
])("POST: %s is 400 with no code", async (_name, refs) => {
|
|
743
|
+
mockGetSession.mockResolvedValue({ user: { id: "u1" } });
|
|
744
|
+
|
|
745
|
+
const res = await POST(post(refs()));
|
|
746
|
+
|
|
747
|
+
expect(res.status).toBe(400);
|
|
748
|
+
expect(res.headers.get("location")).toBeNull();
|
|
749
|
+
expect(mockCreateCode).not.toHaveBeenCalled();
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it("POST: no session sends the visitor to sign in, back through the same continuation", async () => {
|
|
753
|
+
mockGetSession.mockResolvedValue(null);
|
|
754
|
+
const sealed = ref();
|
|
755
|
+
|
|
756
|
+
const res = await POST(post([sealed]));
|
|
757
|
+
|
|
758
|
+
expect(res.status).toBe(303);
|
|
759
|
+
expect(res.headers.get("location")).toBe(
|
|
760
|
+
`/sign-in?next=${encodeURIComponent(`${CONTINUE}/${sealed}`)}`,
|
|
761
|
+
);
|
|
762
|
+
expect(mockCreateCode).not.toHaveBeenCalled();
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it("POST: an unverified email goes to /account/settings; any other refusal is a 400", async () => {
|
|
766
|
+
mockGetSession.mockResolvedValue({ user: { id: "u1" } });
|
|
767
|
+
mockCreateCode.mockResolvedValueOnce({
|
|
768
|
+
ok: false,
|
|
769
|
+
status: 403,
|
|
770
|
+
body: { ok: false, error: { code: "email_unverified", message: "x" } },
|
|
771
|
+
});
|
|
772
|
+
const unverified = await POST(post([ref()]));
|
|
773
|
+
expect(unverified.status).toBe(303);
|
|
774
|
+
expect(unverified.headers.get("location")).toBe("/account/settings");
|
|
775
|
+
|
|
776
|
+
mockCreateCode.mockResolvedValueOnce(CONSENT_REQUIRED);
|
|
777
|
+
const refused = await POST(post([ref()]));
|
|
778
|
+
expect(refused.status).toBe(400);
|
|
779
|
+
expect(refused.headers.get("location")).toBeNull();
|
|
780
|
+
});
|
|
781
|
+
});
|
|
@@ -28,6 +28,11 @@
|
|
|
28
28
|
* the REGISTERED redirect URI the API echoes back, with `code` and the
|
|
29
29
|
* same `state` — never to the query's own `redirect_uri` until the API has
|
|
30
30
|
* confirmed it is the client's;
|
|
31
|
+
* - a person who has not said yes to this spoke (403 `consent_required`,
|
|
32
|
+
* #9966 D2) goes to the hub's own consent page, `/account/connect?ref=…`,
|
|
33
|
+
* with the six keys sealed as the no-session branch seals them. The GET
|
|
34
|
+
* never sends `consent`: a spoke can send a visitor here on page load, so
|
|
35
|
+
* a GET must never be the yes;
|
|
31
36
|
* - a person whose email is not verified (403 `email_unverified`) goes to the
|
|
32
37
|
* hub's own `/account/settings`, whose For-you item says how to verify —
|
|
33
38
|
* never to the spoke;
|
|
@@ -51,6 +56,15 @@
|
|
|
51
56
|
* continuation to `/sign-in` — nor a code or a cookie, and with a session it
|
|
52
57
|
* is ignored: the person has an account, and the spoke signs them in.
|
|
53
58
|
*
|
|
59
|
+
* POST /api/auth/hub/authorize (form field `ref`, #9966 D2)
|
|
60
|
+
*
|
|
61
|
+
* - the consent page's Continue. Accepted only with `Sec-Fetch-Site:
|
|
62
|
+
* same-origin` and an `Origin` equal to the pinned origin (a missing or
|
|
63
|
+
* `null` Origin is refused), before any session read; the ref is opened
|
|
64
|
+
* and its query re-validated, and the session branch runs as GET's does,
|
|
65
|
+
* with `consent: true` — and `x-internal-auth` where the API requires it —
|
|
66
|
+
* on the code request. This is the one call site that sends consent.
|
|
67
|
+
*
|
|
54
68
|
* `BETTER_AUTH_URL` pins the `Origin` sent with the cookie-authenticated write
|
|
55
69
|
* (Node's `fetch` sets none). Unset, the step answers 503: it needs a pinned
|
|
56
70
|
* origin, exactly as the emailed link does.
|
|
@@ -64,13 +78,17 @@ import { hubSignIn } from "@working-theory/api-client";
|
|
|
64
78
|
import {
|
|
65
79
|
accountEmailHintSchema,
|
|
66
80
|
hubAuthorizeQuerySchema,
|
|
81
|
+
type HubAuthorizeQuery,
|
|
67
82
|
} from "@working-theory/validation";
|
|
68
83
|
|
|
69
84
|
import { auth, resolveSecret } from "../../../../../auth";
|
|
85
|
+
import { browserApiOrigin } from "../../../../../lib/api-origin.generated";
|
|
86
|
+
import { TOPOLOGY } from "../../../../../lib/topology.generated";
|
|
70
87
|
import {
|
|
71
88
|
CONTINUATION_REF_MAX,
|
|
72
89
|
CONTINUE_PATH,
|
|
73
90
|
mintContinuation,
|
|
91
|
+
openContinuation,
|
|
74
92
|
} from "../continuation";
|
|
75
93
|
|
|
76
94
|
/** The six query keys, each read exactly once. */
|
|
@@ -86,6 +104,11 @@ const QUERY_KEYS = [
|
|
|
86
104
|
/** Where an unverified person is sent to verify: the hub's own account page. */
|
|
87
105
|
const VERIFY_EMAIL_PATH = "/account/settings";
|
|
88
106
|
|
|
107
|
+
/** The hub's consent page, where a person says yes to one spoke (#9966 D2). */
|
|
108
|
+
const CONSENT_PATH = "/account/connect";
|
|
109
|
+
|
|
110
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1"]);
|
|
111
|
+
|
|
89
112
|
function json(status: number, error: string): Response {
|
|
90
113
|
return new Response(JSON.stringify({ error }), {
|
|
91
114
|
status,
|
|
@@ -120,42 +143,50 @@ function pinnedOrigin(): string | null {
|
|
|
120
143
|
}
|
|
121
144
|
}
|
|
122
145
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
raw[key] = values[0];
|
|
133
|
-
}
|
|
134
|
-
// #8757 D9 — the optional hint: the same once-only rule; refused → dropped.
|
|
135
|
-
const hints = url.searchParams.getAll("login_hint");
|
|
136
|
-
if (hints.length > 1) return json(400, "invalid_request");
|
|
137
|
-
// #9912 D3 — the sign-up hint, by the same rule; only `create` acts.
|
|
138
|
-
const prompts = url.searchParams.getAll("prompt");
|
|
139
|
-
if (prompts.length > 1) return json(400, "invalid_request");
|
|
140
|
-
const parsed = hubAuthorizeQuerySchema.safeParse(raw);
|
|
141
|
-
if (!parsed.success) return json(400, "invalid_request");
|
|
142
|
-
const query = parsed.data;
|
|
143
|
-
const hint = accountEmailHintSchema.safeParse(hints[0]);
|
|
146
|
+
/** The six validated keys as a sealed continuation ref, or `null` past the cap. */
|
|
147
|
+
function sealQuery(query: HubAuthorizeQuery): string | null {
|
|
148
|
+
// Only the six validated keys, in QUERY_KEYS order: a ref carries nothing
|
|
149
|
+
// that did not pass the schema, and extra or reordered keys are dropped.
|
|
150
|
+
const canonical = new URLSearchParams();
|
|
151
|
+
for (const key of QUERY_KEYS) canonical.set(key, query[key]);
|
|
152
|
+
const ref = mintContinuation(canonical.toString(), resolveSecret());
|
|
153
|
+
return ref.length > CONTINUATION_REF_MAX ? null : ref;
|
|
154
|
+
}
|
|
144
155
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
156
|
+
/**
|
|
157
|
+
* `x-internal-auth` for the consent-bearing code request, under the Offerings
|
|
158
|
+
* buy route's `startHeaders` rule: only from a site that is not a spoke, and
|
|
159
|
+
* only to an `https:` or loopback API, so the secret never crosses a plain
|
|
160
|
+
* network hop. The API honours `consent: true` only beside it where it is set.
|
|
161
|
+
*/
|
|
162
|
+
function internalAuthHeader(): Record<string, string> {
|
|
163
|
+
const secret = process.env.OSS_API_INTERNAL_SECRET;
|
|
164
|
+
const spoke = TOPOLOGY === "spoke" || !!process.env.AILK_SPOKE_KEY?.trim();
|
|
165
|
+
if (!secret || spoke) return {};
|
|
166
|
+
try {
|
|
167
|
+
const api = new URL(
|
|
168
|
+
process.env.AILK_API_URL ||
|
|
169
|
+
browserApiOrigin() ||
|
|
170
|
+
process.env.NEXT_PUBLIC_API_URL ||
|
|
171
|
+
"http://localhost:3001",
|
|
172
|
+
);
|
|
173
|
+
const safe = api.protocol === "https:" || LOOPBACK_HOSTS.has(api.hostname);
|
|
174
|
+
return safe ? { "x-internal-auth": secret } : {};
|
|
175
|
+
} catch {
|
|
176
|
+
return {};
|
|
157
177
|
}
|
|
178
|
+
}
|
|
158
179
|
|
|
180
|
+
/**
|
|
181
|
+
* The session branch, shared by GET and the consent POST: ask the API for a
|
|
182
|
+
* code and answer with where the visitor goes next. Only the POST passes
|
|
183
|
+
* `consent` (#9966 D2).
|
|
184
|
+
*/
|
|
185
|
+
async function codeRedirect(
|
|
186
|
+
request: Request,
|
|
187
|
+
query: HubAuthorizeQuery,
|
|
188
|
+
consent: boolean,
|
|
189
|
+
): Promise<Response> {
|
|
159
190
|
const origin = pinnedOrigin();
|
|
160
191
|
if (origin === null) return json(503, "temporarily_unavailable");
|
|
161
192
|
|
|
@@ -166,11 +197,13 @@ export async function GET(request: Request): Promise<Response> {
|
|
|
166
197
|
clientId: query.client_id,
|
|
167
198
|
redirectUri: query.redirect_uri,
|
|
168
199
|
codeChallenge: query.code_challenge,
|
|
200
|
+
...(consent ? { consent: true as const } : {}),
|
|
169
201
|
},
|
|
170
202
|
{
|
|
171
203
|
headers: {
|
|
172
204
|
cookie: request.headers.get("cookie") ?? "",
|
|
173
205
|
origin,
|
|
206
|
+
...(consent ? internalAuthHeader() : {}),
|
|
174
207
|
},
|
|
175
208
|
},
|
|
176
209
|
);
|
|
@@ -198,5 +231,90 @@ export async function GET(request: Request): Promise<Response> {
|
|
|
198
231
|
if (result.status === 403 && code === "email_unverified") {
|
|
199
232
|
return see(VERIFY_EMAIL_PATH);
|
|
200
233
|
}
|
|
234
|
+
// Not yet said yes to this spoke: ask, on the hub's own page. The POST that
|
|
235
|
+
// page sends already carries consent, so it never comes back here.
|
|
236
|
+
if (!consent && result.status === 403 && code === "consent_required") {
|
|
237
|
+
const ref = sealQuery(query);
|
|
238
|
+
if (ref === null) return json(400, "invalid_request");
|
|
239
|
+
return see(`${CONSENT_PATH}?${new URLSearchParams({ ref }).toString()}`);
|
|
240
|
+
}
|
|
201
241
|
return json(400, "invalid_request");
|
|
202
242
|
}
|
|
243
|
+
|
|
244
|
+
// platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
|
|
245
|
+
export async function GET(request: Request): Promise<Response> {
|
|
246
|
+
const url = new URL(request.url);
|
|
247
|
+
|
|
248
|
+
// Each declared key exactly once: a repeated key is ambiguous, so 400.
|
|
249
|
+
const raw: Record<string, string | undefined> = {};
|
|
250
|
+
for (const key of QUERY_KEYS) {
|
|
251
|
+
const values = url.searchParams.getAll(key);
|
|
252
|
+
if (values.length > 1) return json(400, "invalid_request");
|
|
253
|
+
raw[key] = values[0];
|
|
254
|
+
}
|
|
255
|
+
// #8757 D9 — the optional hint: the same once-only rule; refused → dropped.
|
|
256
|
+
const hints = url.searchParams.getAll("login_hint");
|
|
257
|
+
if (hints.length > 1) return json(400, "invalid_request");
|
|
258
|
+
// #9912 D3 — the sign-up hint, by the same rule; only `create` acts.
|
|
259
|
+
const prompts = url.searchParams.getAll("prompt");
|
|
260
|
+
if (prompts.length > 1) return json(400, "invalid_request");
|
|
261
|
+
const parsed = hubAuthorizeQuerySchema.safeParse(raw);
|
|
262
|
+
if (!parsed.success) return json(400, "invalid_request");
|
|
263
|
+
const query = parsed.data;
|
|
264
|
+
const hint = accountEmailHintSchema.safeParse(hints[0]);
|
|
265
|
+
|
|
266
|
+
const session = await auth.api.getSession({ headers: request.headers });
|
|
267
|
+
if (!session) {
|
|
268
|
+
const ref = sealQuery(query);
|
|
269
|
+
if (ref === null) return json(400, "invalid_request");
|
|
270
|
+
const signIn = new URLSearchParams({ next: `${CONTINUE_PATH}/${ref}` });
|
|
271
|
+
if (prompts[0] === "create") return see(`/sign-up?${signIn.toString()}`);
|
|
272
|
+
if (hint.success) signIn.set("email", hint.data);
|
|
273
|
+
return see(`/sign-in?${signIn.toString()}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return codeRedirect(request, query, false);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The consent page's Continue (#9966 D2): the one request that says yes to a
|
|
281
|
+
* spoke. Same-origin only, decided before any session read, so neither a
|
|
282
|
+
* cross-site form nor a sandboxed frame (`Origin: null`) can post it.
|
|
283
|
+
*/
|
|
284
|
+
export async function POST(request: Request): Promise<Response> {
|
|
285
|
+
const origin = pinnedOrigin();
|
|
286
|
+
if (origin === null) return json(503, "temporarily_unavailable");
|
|
287
|
+
const sent = request.headers.get("origin");
|
|
288
|
+
if (
|
|
289
|
+
request.headers.get("sec-fetch-site") !== "same-origin" ||
|
|
290
|
+
sent === null ||
|
|
291
|
+
sent !== origin
|
|
292
|
+
) {
|
|
293
|
+
return json(403, "invalid_request");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let refs: FormDataEntryValue[];
|
|
297
|
+
try {
|
|
298
|
+
refs = (await request.formData()).getAll("ref");
|
|
299
|
+
} catch {
|
|
300
|
+
return json(400, "invalid_request");
|
|
301
|
+
}
|
|
302
|
+
const [ref] = refs;
|
|
303
|
+
if (refs.length !== 1 || typeof ref !== "string") {
|
|
304
|
+
return json(400, "invalid_request");
|
|
305
|
+
}
|
|
306
|
+
const search = openContinuation(ref, resolveSecret());
|
|
307
|
+
if (search === null) return json(400, "invalid_request");
|
|
308
|
+
const params = new URLSearchParams(search);
|
|
309
|
+
const raw: Record<string, string | undefined> = {};
|
|
310
|
+
for (const key of QUERY_KEYS) raw[key] = params.get(key) ?? undefined;
|
|
311
|
+
const parsed = hubAuthorizeQuerySchema.safeParse(raw);
|
|
312
|
+
if (!parsed.success) return json(400, "invalid_request");
|
|
313
|
+
|
|
314
|
+
const session = await auth.api.getSession({ headers: request.headers });
|
|
315
|
+
if (!session) {
|
|
316
|
+
const signIn = new URLSearchParams({ next: `${CONTINUE_PATH}/${ref}` });
|
|
317
|
+
return see(`/sign-in?${signIn.toString()}`);
|
|
318
|
+
}
|
|
319
|
+
return codeRedirect(request, parsed.data, true);
|
|
320
|
+
}
|