create-ailk 0.8.0 → 0.10.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 +71 -3
- package/component-catalog.json +279 -45
- package/dist/add-module.d.ts +20 -0
- package/dist/add-module.js +43 -7
- package/dist/add-route.d.ts +56 -0
- package/dist/add-route.js +286 -0
- package/dist/apply-design-md.d.ts +7 -0
- package/dist/apply-design-md.js +50 -0
- package/dist/apply-design.d.ts +64 -0
- package/dist/apply-design.js +273 -0
- package/dist/claude-md.d.ts +111 -0
- package/dist/claude-md.js +334 -0
- package/dist/cli.js +104 -5
- package/dist/derive-architecture.js +41 -36
- package/dist/design-compile.d.ts +71 -0
- package/dist/design-compile.js +98 -0
- package/dist/design-package.d.ts +83 -0
- package/dist/design-package.js +468 -0
- package/dist/design-presets.d.ts +86 -0
- package/dist/design-presets.js +642 -0
- package/dist/design-scaffold.d.ts +76 -0
- package/dist/design-scaffold.js +220 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/legacy-nav-renderers.d.ts +45 -0
- package/dist/legacy-nav-renderers.js +545 -0
- package/dist/lib/auth-secret-file.d.ts +19 -0
- package/dist/lib/auth-secret-file.js +112 -0
- package/dist/lib/design-checks.d.ts +78 -0
- package/dist/lib/design-checks.js +174 -0
- package/dist/lib/license-key-files.d.ts +9 -0
- package/dist/lib/license-key-files.js +2 -2
- package/dist/lib/module-license-gate.d.ts +48 -32
- package/dist/lib/module-license-gate.js +117 -8
- package/dist/lib/render-verdict.d.ts +52 -0
- package/dist/lib/render-verdict.js +77 -0
- package/dist/lib/scaffold-render-sweep.d.ts +105 -0
- package/dist/lib/scaffold-render-sweep.js +236 -0
- package/dist/module-architecture.d.ts +19 -16
- package/dist/module-architecture.js +67 -23
- package/dist/parse-args.d.ts +12 -2
- package/dist/parse-args.js +74 -13
- package/dist/programmatic.d.ts +30 -6
- package/dist/programmatic.js +216 -19
- package/dist/site-chrome.d.ts +19 -9
- package/dist/site-chrome.js +23 -13
- package/dist/site-manifest.d.ts +25 -0
- package/dist/site-manifest.js +254 -0
- package/dist/site-roster.d.ts +41 -0
- package/dist/site-roster.js +141 -8
- package/dist/starter-grid.d.ts +16 -63
- package/dist/starter-grid.js +102 -84
- package/dist/surfaces.d.ts +41 -9
- package/dist/surfaces.js +200 -83
- package/dist/template-manifest.d.ts +30 -13
- package/dist/template-manifest.js +53 -28
- package/package.json +6 -3
- package/release-scope.json +25 -0
- package/site-templates/_base/content/en/UseCase/persona-1.mdx +7 -6
- package/site-templates/_base/content/en/UseCase/persona-2.mdx +7 -6
- package/site-templates/_base/content/en/UseCase/persona-3.mdx +7 -6
- package/site-templates/designs/minimalism/DESIGN.md +98 -6
- package/site-templates/designs/minimalism/sample/README.md +48 -0
- package/site-templates/{minimalism-site → designs/minimalism/sample}/content/en/HomePage/home.mdx +1 -1
- package/site-templates/designs/standard/DESIGN.md +98 -6
- package/site-templates/marketing-site/DESIGN.md +98 -6
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +60 -27
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +50 -30
- package/site-templates/marketing-site-free/DESIGN.md +98 -6
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +60 -24
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +44 -27
- package/site-templates/marketing-site-free/ailk-template.json +1 -1
- package/site-templates/saas-product/DESIGN.md +98 -6
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +74 -20
- package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +74 -20
- package/site-templates/shapes/organization-service/ailk-template.json +1 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +75 -21
- package/site-templates/shapes/person-service/ailk-template.json +1 -1
- package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +1 -1
- package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +2 -2
- package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +4 -2
- package/site-templates/shapes/person-service/content/en/ServicesList/services.mdx +1 -1
- package/templates/.claude/agents/web.md +10 -8
- package/templates/.claude/rules/architecture.md +17 -7
- package/templates/.claude/skills/README.md +3 -2
- package/templates/.claude/skills/add-auth/SKILL.md +142 -90
- package/templates/.claude/skills/add-auth/references/env-vars.md +32 -17
- package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +17 -8
- package/templates/.claude/skills/add-page/SKILL.md +241 -238
- package/templates/.claude/skills/add-page/references/page-types.md +94 -86
- package/templates/.claude/skills/add-page/scripts/site-pages.mjs +87 -0
- package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +99 -0
- package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +8 -18
- package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +43 -0
- package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +52 -0
- package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +38 -0
- package/templates/.claude/skills/check-site/SKILL.md +51 -18
- package/templates/.claude/skills/check-site/references/common-errors.md +12 -9
- package/templates/.claude/skills/check-site/references/findings-glossary.md +4 -4
- package/templates/.claude/skills/check-site/references/severity-levels.md +3 -3
- package/templates/.claude/skills/fill-page/SKILL.md +26 -7
- package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +1 -1
- package/templates/.claude/skills/setup-deploy/SKILL.md +75 -38
- package/templates/.claude/skills/update-page/SKILL.md +117 -0
- package/templates/.claude/skills/update-page/scripts/update-page.ts +640 -0
- package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +31 -0
- package/templates/.claude/skills/upgrade-plan/SKILL.md +12 -8
- package/templates/.env.example +23 -2
- package/templates/.github/workflows/ci.yml +4 -0
- package/templates/.prettierignore +1 -0
- package/templates/CLAUDE.md +146 -103
- package/templates/DESIGN.md +98 -6
- package/templates/README.md +50 -38
- package/templates/apps/api/.env.example +2 -1
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +2 -0
- package/templates/apps/api/src/__tests__/server.test.ts +116 -0
- package/templates/apps/api/src/lib/stripe.ts +9 -0
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +140 -0
- package/templates/apps/api/src/middleware/auth.ts +54 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +5 -2
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +628 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +222 -24
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +5 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +521 -20
- package/templates/apps/api/src/routes/webhooks/stripe.ts +124 -32
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +90 -15
- package/templates/apps/api/src/services/checkout-buyer.ts +72 -0
- package/templates/apps/api/src/services/revalidation.ts +60 -15
- package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
- package/templates/apps/mcp/src/tools/purchase.ts +4 -2
- package/templates/apps/web/.env.example +13 -0
- package/templates/apps/web/CLAUDE.md +22 -28
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +1 -1
- package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +24 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +17 -6
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +110 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/alternatives/__tests__/page.test.tsx +161 -0
- package/templates/apps/web/app/[locale]/alternatives/page.tsx +104 -0
- package/templates/apps/web/app/[locale]/{use-cases → for}/[slug]/page.tsx +4 -3
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +73 -0
- package/templates/apps/web/app/[locale]/{use-cases → for}/page.tsx +8 -7
- package/templates/apps/web/app/[locale]/glossary/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/layout.tsx +35 -19
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +112 -0
- package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +70 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +236 -44
- package/templates/apps/web/app/api/revalidate/route.ts +78 -36
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +79 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +104 -11
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +21 -4
- package/templates/apps/web/lib/locale-guard.ts +18 -0
- package/templates/apps/web/lib/meet-placeholder.ts +5 -0
- package/templates/apps/web/lib/nav-config.ts +14 -13
- package/templates/apps/web/lib/site-brand.tsx +13 -9
- package/templates/apps/web/lib/site-logo.tsx +36 -0
- package/templates/apps/web/lib/site-theme.ts +46 -26
- package/templates/apps/web/messages/en.json +23 -9
- package/templates/apps/web/middleware.ts +28 -4
- package/templates/content/en/BookCall/book-a-call.mdx +80 -0
- package/templates/content/en/BookCall/onsite-estimate.mdx +56 -0
- package/templates/content/en/BookCall/onsite-service.mdx +56 -0
- package/templates/content/en/BookCall/start-project.mdx +56 -0
- package/templates/content/en/ContactPage/contact.mdx +40 -1
- package/templates/content/en/HomePage/home.mdx +2 -6
- package/templates/content/en/LandingPage/meet.mdx +59 -0
- package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/templates/package.json +4 -1
- package/templates/tools/check-claude-md.ts +128 -0
- package/versions.json +33 -33
- package/dist/modules/commerce/0.6.0.tar.gz +0 -0
- package/dist/modules/commerce/0.6.1.tar.gz +0 -0
- package/dist/modules/docs-blog/0.6.0.tar.gz +0 -0
- package/dist/modules/docs-blog/0.6.1.tar.gz +0 -0
- package/dist/modules/marketing-site/0.6.1.tar.gz +0 -0
- package/dist/modules/minimalism-site/0.6.1.tar.gz +0 -0
- package/dist/modules/multi-tenant/0.6.0.tar.gz +0 -0
- package/dist/modules/multi-tenant/0.6.1.tar.gz +0 -0
- package/dist/modules/saas-product/0.6.1.tar.gz +0 -0
- package/dist/modules/scheduling/0.6.0.tar.gz +0 -0
- package/dist/modules/scheduling/0.6.1.tar.gz +0 -0
- package/dist/modules/signal-collectors/0.6.0.tar.gz +0 -0
- package/dist/modules/signal-collectors/0.6.1.tar.gz +0 -0
- package/dist/modules/simple-service/0.6.1.tar.gz +0 -0
- package/dist/modules/workspace/0.6.0.tar.gz +0 -0
- package/dist/modules/workspace/0.6.1.tar.gz +0 -0
- package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -52
- package/site-templates/minimalism-site/DESIGN.md +0 -162
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +0 -506
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -458
- package/site-templates/minimalism-site/ailk-template.json +0 -23
- package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +0 -110
- package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +0 -34
- package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +0 -57
- package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +0 -77
- package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +0 -73
- package/site-templates/minimalism-site/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -123
- package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +0 -52
- package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +0 -73
- package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
- package/site-templates/minimalism-site/provenance.json +0 -4
- package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
- package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +0 -175
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +0 -8
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +0 -12
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +0 -12
- package/templates/apps/web/app/[locale]/book-a-call/page.tsx +0 -50
- package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-1.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-2.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-3.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-4.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-5.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-6.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/hero-backdrop.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-1.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-2.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-3.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/wordmark-placeholder.svg +0 -0
|
@@ -12,19 +12,40 @@
|
|
|
12
12
|
* Only the first 8 chars of the session ID appear in log lines.
|
|
13
13
|
* - Error responses never echo user input (productCode, URLs).
|
|
14
14
|
* - No DB write: this unit creates a Stripe session only; webhook-driven
|
|
15
|
-
* persistence is a follow-up issue.
|
|
15
|
+
* persistence is a follow-up issue. It READS the database only to resolve
|
|
16
|
+
* an optional signed-in buyer (below).
|
|
17
|
+
* - Optional cookie identity (#6866): a valid, same-origin Better Auth
|
|
18
|
+
* session cookie whose user has a verified email makes the Checkout a
|
|
19
|
+
* signed-in one — the session is pinned to the buyer's Stripe customer and
|
|
20
|
+
* stamped `metadata.ailk_user_id`, so the #6852 webhook binds it. The id
|
|
21
|
+
* comes ONLY from the session cookie, never the body; a bearer token never
|
|
22
|
+
* identifies a buyer. Any lookup failure, and a pinned customer Stripe
|
|
23
|
+
* does not know, degrades to today's anonymous Checkout — never an error.
|
|
24
|
+
* The stamped id is never echoed or logged.
|
|
16
25
|
* - X-Internal-Auth gate: when OSS_API_INTERNAL_SECRET env var is set,
|
|
17
26
|
* the X-Internal-Auth header must match; requests without a matching header
|
|
18
27
|
* are rejected with 401. When the env var is unset, no gate is applied
|
|
19
28
|
* (backward-compat for OSS standalone deployments). See #935.
|
|
29
|
+
* - Same-origin redirects (#6877): a request that did not pass an ENABLED
|
|
30
|
+
* internal-auth gate (the OSS default) must send successUrl and cancelUrl
|
|
31
|
+
* on the site's own origin — `FRONTEND_URL`'s, exact scheme + host + port,
|
|
32
|
+
* checked on the raw string as well as on the parse, because the raw
|
|
33
|
+
* string is what Stripe receives. Anything else is a 400 before any Stripe
|
|
34
|
+
* call or database read. A caller authenticated by X-Internal-Auth keeps
|
|
35
|
+
* its redirects; that exemption holds only while every internal caller
|
|
36
|
+
* builds its redirects itself — an internal caller MUST NEVER forward a
|
|
37
|
+
* redirect taken from its own end user.
|
|
20
38
|
*
|
|
21
39
|
* Error mapping:
|
|
22
40
|
* 401 internal-auth-required — X-Internal-Auth missing/mismatch (only when env var set)
|
|
23
|
-
* 400 validation-error — Zod validation failed (missing/invalid fields)
|
|
41
|
+
* 400 validation-error — Zod validation failed (missing/invalid fields), or an
|
|
42
|
+
* unauthenticated redirect is off the site origin (#6877)
|
|
24
43
|
* 404 unknown-product-code — no active Stripe price for the given productCode
|
|
25
44
|
* 500 stripe-not-configured — STRIPE_SECRET_KEY is missing/empty (no Stripe call)
|
|
26
45
|
* 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key
|
|
27
|
-
* 500 config-error — STRIPE_SECRET_KEY invalid configuration (from the SDK wrapper)
|
|
46
|
+
* 500 config-error — STRIPE_SECRET_KEY invalid configuration (from the SDK wrapper),
|
|
47
|
+
* or FRONTEND_URL is not an absolute http(s) URL / is unset in
|
|
48
|
+
* production when the origin lock applies (#6877)
|
|
28
49
|
* 502 checkout-unavailable — Stripe SDK threw an unexpected error
|
|
29
50
|
*/
|
|
30
51
|
import { timingSafeEqual } from "node:crypto";
|
|
@@ -32,12 +53,28 @@ import { timingSafeEqual } from "node:crypto";
|
|
|
32
53
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
33
54
|
import { z } from "zod";
|
|
34
55
|
|
|
56
|
+
import { config } from "../../config/index.js";
|
|
35
57
|
import {
|
|
36
58
|
assertSandboxKey,
|
|
37
59
|
createCheckoutSession,
|
|
38
60
|
StripeConfigError,
|
|
61
|
+
UnknownCustomerError,
|
|
39
62
|
UnknownProductCodeError,
|
|
40
63
|
} from "../../lib/stripe.js";
|
|
64
|
+
import type {
|
|
65
|
+
CreateCheckoutSessionInput,
|
|
66
|
+
CreateCheckoutSessionOutput,
|
|
67
|
+
} from "../../lib/stripe.js";
|
|
68
|
+
import {
|
|
69
|
+
type OptionalCookieUser,
|
|
70
|
+
resolveOptionalCookieUser,
|
|
71
|
+
} from "../../middleware/auth.js";
|
|
72
|
+
import {
|
|
73
|
+
type CheckoutBuyerResult,
|
|
74
|
+
resolveCheckoutBuyer,
|
|
75
|
+
} from "../../services/checkout-buyer.js";
|
|
76
|
+
|
|
77
|
+
type OptionalCookieUserNone = Extract<OptionalCookieUser, { kind: "none" }>;
|
|
41
78
|
|
|
42
79
|
// ── Input validation schema ───────────────────────────────────────────────────
|
|
43
80
|
|
|
@@ -81,14 +118,13 @@ const CheckoutSessionRequestSchema = z.object({
|
|
|
81
118
|
// ── Internal-auth gate ────────────────────────────────────────────────────────
|
|
82
119
|
|
|
83
120
|
/**
|
|
84
|
-
*
|
|
121
|
+
* Classify the request against the X-Internal-Auth gate (#935, #6877 D3).
|
|
85
122
|
*
|
|
86
123
|
* Gate is env-var-conditional:
|
|
87
|
-
* - env var SET
|
|
88
|
-
* - env var
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* rejected with 401.
|
|
124
|
+
* - env var SET, header missing or wrong → "refused" (401)
|
|
125
|
+
* - env var SET, header matches → "authenticated" (redirects kept)
|
|
126
|
+
* - env var UNSET → "open" (OSS standalone backward-
|
|
127
|
+
* compat; the request is anonymous, so the origin lock applies)
|
|
92
128
|
*
|
|
93
129
|
* Uses a constant-time string comparison to prevent timing attacks on the
|
|
94
130
|
* secret value. Node's `crypto.timingSafeEqual` requires equal-length Buffers,
|
|
@@ -96,20 +132,116 @@ const CheckoutSessionRequestSchema = z.object({
|
|
|
96
132
|
* no information about the secret's content beyond its length, which is
|
|
97
133
|
* acceptable for a shared internal secret).
|
|
98
134
|
*/
|
|
99
|
-
function
|
|
135
|
+
function internalAuthVerdict(
|
|
136
|
+
request: FastifyRequest,
|
|
137
|
+
): "refused" | "open" | "authenticated" {
|
|
100
138
|
const expectedSecret = process.env["OSS_API_INTERNAL_SECRET"];
|
|
101
139
|
// Gate is disabled when env var is not set — OSS standalone backward-compat.
|
|
102
|
-
if (!expectedSecret) return
|
|
140
|
+
if (!expectedSecret) return "open";
|
|
103
141
|
|
|
104
142
|
const provided = request.headers["x-internal-auth"];
|
|
105
|
-
if (typeof provided !== "string" || !provided) return
|
|
143
|
+
if (typeof provided !== "string" || !provided) return "refused";
|
|
106
144
|
|
|
107
145
|
// Constant-time comparison.
|
|
108
146
|
const expectedBuf = Buffer.from(expectedSecret, "utf8");
|
|
109
147
|
const providedBuf = Buffer.from(provided, "utf8");
|
|
110
|
-
if (expectedBuf.length !== providedBuf.length) return
|
|
148
|
+
if (expectedBuf.length !== providedBuf.length) return "refused";
|
|
149
|
+
|
|
150
|
+
return timingSafeEqual(expectedBuf, providedBuf)
|
|
151
|
+
? "authenticated"
|
|
152
|
+
: "refused";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Same-origin redirect lock (#6877) ────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The site's own origin, from `FRONTEND_URL` (#6877 D6). `null` when the
|
|
159
|
+
* setting does not parse, parses to a non-http(s) scheme (whose origin would
|
|
160
|
+
* be the literal `"null"`), or is unset in production — where the config
|
|
161
|
+
* default `http://localhost:3000` would lock a live site to localhost.
|
|
162
|
+
*/
|
|
163
|
+
function siteRedirectOrigin(): string | null {
|
|
164
|
+
if (!process.env["FRONTEND_URL"] && config.NODE_ENV === "production") {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
let parsed: URL;
|
|
168
|
+
try {
|
|
169
|
+
parsed = new URL(config.FRONTEND_URL);
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
|
|
174
|
+
return parsed.origin;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** C0 controls, space, DEL and backslash — anything a parser may normalize. */
|
|
178
|
+
// eslint-disable-next-line no-control-regex -- the control-character range is exactly what D2 check 1 must refuse.
|
|
179
|
+
const NON_CANONICAL_URL_CHAR = /[\u0000-\u0020\u007f\\]/;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* True only when `raw` is on `siteOrigin` both as written and as parsed
|
|
183
|
+
* (#6877 D2). The raw-string checks matter because the route forwards `raw`
|
|
184
|
+
* itself to Stripe: WHATWG parses `https://example.com\@evil.test/x` to
|
|
185
|
+
* origin `https://example.com`, and another parser may not. Requiring the
|
|
186
|
+
* literal origin followed by `/`, `?`, `#` or the end keeps
|
|
187
|
+
* `https://example.com.evil.test` out, and exact origin equality keeps a
|
|
188
|
+
* scheme downgrade or another port out.
|
|
189
|
+
*/
|
|
190
|
+
function isSameOriginRedirect(raw: string, siteOrigin: string): boolean {
|
|
191
|
+
if (NON_CANONICAL_URL_CHAR.test(raw)) return false;
|
|
192
|
+
if (!raw.startsWith(siteOrigin)) return false;
|
|
193
|
+
const boundary = raw.charAt(siteOrigin.length);
|
|
194
|
+
if (boundary !== "" && !"/?#".includes(boundary)) return false;
|
|
195
|
+
let resolved: URL;
|
|
196
|
+
try {
|
|
197
|
+
resolved = new URL(raw);
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
if (resolved.protocol !== "https:" && resolved.protocol !== "http:")
|
|
202
|
+
return false;
|
|
203
|
+
return resolved.origin === siteOrigin;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Optional signed-in buyer (#6866) ─────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
type Buyer = NonNullable<CreateCheckoutSessionInput["buyer"]>;
|
|
209
|
+
|
|
210
|
+
/** Why a present cookie produced no buyer — the closed set D6 logs. */
|
|
211
|
+
type IdentityReason =
|
|
212
|
+
| Exclude<OptionalCookieUserNone["reason"], "no_cookie">
|
|
213
|
+
| Extract<CheckoutBuyerResult, { ok: false }>["reason"]
|
|
214
|
+
| "buyer_lookup_failed"
|
|
215
|
+
| "buyer_invalid"
|
|
216
|
+
| "stale_customer";
|
|
111
217
|
|
|
112
|
-
|
|
218
|
+
/**
|
|
219
|
+
* Resolve the signed-in buyer from the session cookie alone (#6866 D2/D3).
|
|
220
|
+
* Never throws: a failed lookup is anonymous (F7). `identity` is the reason a
|
|
221
|
+
* present cookie produced no buyer — logged, never the user's id; absent for
|
|
222
|
+
* a call with no cookie (D6).
|
|
223
|
+
*/
|
|
224
|
+
async function resolveBuyer(
|
|
225
|
+
request: FastifyRequest,
|
|
226
|
+
): Promise<{ buyer?: Buyer; identity?: IdentityReason }> {
|
|
227
|
+
const cookieUser = await resolveOptionalCookieUser(request, {
|
|
228
|
+
frontendOrigin: config.FRONTEND_URL,
|
|
229
|
+
});
|
|
230
|
+
if (cookieUser.kind === "none") {
|
|
231
|
+
return cookieUser.reason === "no_cookie"
|
|
232
|
+
? {}
|
|
233
|
+
: { identity: cookieUser.reason };
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
const result = await resolveCheckoutBuyer(cookieUser.userId);
|
|
237
|
+
if (!result.ok) return { identity: result.reason };
|
|
238
|
+
const { userId, email, customerId } = result.buyer;
|
|
239
|
+
return {
|
|
240
|
+
buyer: { userId, email, ...(customerId !== null && { customerId }) },
|
|
241
|
+
};
|
|
242
|
+
} catch {
|
|
243
|
+
return { identity: "buyer_lookup_failed" };
|
|
244
|
+
}
|
|
113
245
|
}
|
|
114
246
|
|
|
115
247
|
// ── Handler ───────────────────────────────────────────────────────────────────
|
|
@@ -119,7 +251,8 @@ export async function checkoutSessionsHandler(
|
|
|
119
251
|
reply: FastifyReply,
|
|
120
252
|
): Promise<void> {
|
|
121
253
|
// ── X-Internal-Auth gate (env-var-conditional) ────────────────────────────
|
|
122
|
-
|
|
254
|
+
const authVerdict = internalAuthVerdict(request);
|
|
255
|
+
if (authVerdict === "refused") {
|
|
123
256
|
return reply.status(401).send({ error: "internal-auth-required" });
|
|
124
257
|
}
|
|
125
258
|
|
|
@@ -144,16 +277,79 @@ export async function checkoutSessionsHandler(
|
|
|
144
277
|
const { productCode, successUrl, cancelUrl, quantity, allowPromotionCodes } =
|
|
145
278
|
parsed.data;
|
|
146
279
|
|
|
280
|
+
// ── Same-origin redirect lock (#6877) — before any Stripe or DB call ──────
|
|
281
|
+
if (authVerdict === "open") {
|
|
282
|
+
const siteOrigin = siteRedirectOrigin();
|
|
283
|
+
if (siteOrigin === null) {
|
|
284
|
+
request.log.error(
|
|
285
|
+
{ method: request.method, path: request.url },
|
|
286
|
+
"[checkout] FRONTEND_URL is not a valid absolute URL",
|
|
287
|
+
);
|
|
288
|
+
return reply.status(500).send({ error: "config-error" });
|
|
289
|
+
}
|
|
290
|
+
const refused = (["successUrl", "cancelUrl"] as const).filter(
|
|
291
|
+
(field) => !isSameOriginRedirect(parsed.data[field], siteOrigin),
|
|
292
|
+
);
|
|
293
|
+
if (refused.length > 0) {
|
|
294
|
+
// Field names only — the refused URL is never logged or echoed.
|
|
295
|
+
request.log.warn(
|
|
296
|
+
{
|
|
297
|
+
method: request.method,
|
|
298
|
+
path: request.url,
|
|
299
|
+
outcome: "redirect_origin_refused",
|
|
300
|
+
fields: refused,
|
|
301
|
+
},
|
|
302
|
+
"[checkout] redirect origin refused",
|
|
303
|
+
);
|
|
304
|
+
return reply.status(400).send({
|
|
305
|
+
error: "validation-error",
|
|
306
|
+
details: refused.map((field) => ({
|
|
307
|
+
code: "custom",
|
|
308
|
+
path: [field],
|
|
309
|
+
message: `${field} must be on this site's origin`,
|
|
310
|
+
})),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const sessionInput: CreateCheckoutSessionInput = {
|
|
316
|
+
productCode,
|
|
317
|
+
successUrl,
|
|
318
|
+
cancelUrl,
|
|
319
|
+
quantity: quantity ?? 1,
|
|
320
|
+
// Omitted when the caller says nothing — Stripe's default (no promo-code
|
|
321
|
+
// field) is unchanged for every existing caller.
|
|
322
|
+
...(allowPromotionCodes !== undefined && { allowPromotionCodes }),
|
|
323
|
+
};
|
|
324
|
+
// Built only from the session cookie — never from `parsed.data` (#6866 D5).
|
|
325
|
+
let { buyer, identity } = await resolveBuyer(request);
|
|
326
|
+
|
|
147
327
|
try {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
328
|
+
let created: CreateCheckoutSessionOutput;
|
|
329
|
+
try {
|
|
330
|
+
created = await createCheckoutSession({
|
|
331
|
+
...sessionInput,
|
|
332
|
+
...(buyer !== undefined && { buyer }),
|
|
333
|
+
});
|
|
334
|
+
} catch (err) {
|
|
335
|
+
// The buyer itself cannot be used — a pinned customer Stripe does not
|
|
336
|
+
// know (#6866 D8), or a stored email / customer id the library's schema
|
|
337
|
+
// refuses (F7: no new failure mode). Retry once as anonymous; the
|
|
338
|
+
// stored binding is never touched here. The request fields already
|
|
339
|
+
// passed the route's schema, so an anonymous retry that still fails
|
|
340
|
+
// maps exactly as today.
|
|
341
|
+
const reason: IdentityReason | undefined =
|
|
342
|
+
err instanceof UnknownCustomerError
|
|
343
|
+
? "stale_customer"
|
|
344
|
+
: err instanceof Error && err.name === "ZodError"
|
|
345
|
+
? "buyer_invalid"
|
|
346
|
+
: undefined;
|
|
347
|
+
if (buyer === undefined || reason === undefined) throw err;
|
|
348
|
+
buyer = undefined;
|
|
349
|
+
identity = reason;
|
|
350
|
+
created = await createCheckoutSession(sessionInput);
|
|
351
|
+
}
|
|
352
|
+
const { sessionId, url } = created;
|
|
157
353
|
|
|
158
354
|
request.log.info(
|
|
159
355
|
{
|
|
@@ -161,6 +357,8 @@ export async function checkoutSessionsHandler(
|
|
|
161
357
|
path: request.url,
|
|
162
358
|
sessionIdPrefix: sessionId.substring(0, 8),
|
|
163
359
|
status: 201,
|
|
360
|
+
buyer: buyer !== undefined ? "signed_in" : "anonymous",
|
|
361
|
+
...(identity !== undefined && { identity }),
|
|
164
362
|
},
|
|
165
363
|
"[checkout] session created",
|
|
166
364
|
);
|
|
@@ -170,7 +170,7 @@ describe("AC-1 — completed stamps the row once, guarded, beside the legacy tas
|
|
|
170
170
|
expect(prisma.user.findFirst).toHaveBeenCalledWith({
|
|
171
171
|
where: { billingProvider: "stripe", billingCustomerId: CUSTOMER_ID },
|
|
172
172
|
});
|
|
173
|
-
expect(prisma.user.findUnique).
|
|
173
|
+
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
174
174
|
});
|
|
175
175
|
});
|
|
176
176
|
|
|
@@ -232,7 +232,11 @@ describe("POST /api/webhooks/stripe — org-scoped billing (#3601)", () => {
|
|
|
232
232
|
(prisma.user.update as jest.Mock).mockResolvedValueOnce({ id: "usr_st" });
|
|
233
233
|
|
|
234
234
|
mockVerifyWebhookSignature.mockReturnValue(
|
|
235
|
-
checkoutEvent(
|
|
235
|
+
checkoutEvent(
|
|
236
|
+
"evt_st_004",
|
|
237
|
+
{ productId: "pro", ailk_user_id: "usr_st" },
|
|
238
|
+
customerId,
|
|
239
|
+
),
|
|
236
240
|
);
|
|
237
241
|
const r = await fire(server);
|
|
238
242
|
expect(r.statusCode).toBe(200);
|