create-ailk 0.11.1 → 0.11.3
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/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/content-payloads.js +2 -2
- package/dist/module-architecture.js +11 -0
- package/dist/surfaces.d.ts +4 -5
- package/dist/surfaces.js +50 -8
- package/package.json +3 -3
- package/templates/.env.example +42 -3
- package/templates/apps/api/.env.example +5 -0
- package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
- package/templates/apps/api/src/__tests__/server.test.ts +5 -1
- package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
- package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
- package/templates/apps/api/src/config/index.ts +136 -0
- package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
- package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
- package/templates/apps/api/src/middleware/auth.ts +19 -21
- package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
- package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
- package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
- package/templates/apps/api/src/server.ts +49 -16
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
- package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
- package/templates/apps/api/src/vercel-handler.ts +58 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
- package/templates/apps/web/.env.example +4 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
- package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
- package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
- package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
- package/templates/apps/web/app/[locale]/layout.tsx +98 -84
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
- package/templates/apps/web/app/openapi.json/route.ts +127 -0
- package/templates/apps/web/auth.ts +102 -18
- package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
- package/templates/apps/web/components/signed-in-nav.tsx +58 -0
- package/templates/apps/web/jest.config.cjs +5 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
- package/templates/apps/web/lib/account-config.ts +219 -11
- package/templates/apps/web/lib/auth-email.ts +104 -2
- package/templates/apps/web/lib/auth-env.ts +37 -5
- package/templates/apps/web/lib/auth-routes.ts +33 -4
- package/templates/apps/web/messages/en.json +147 -21
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
- package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
package/component-catalog.json
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"importPath": "@working-theory/templates/shells",
|
|
50
50
|
"rootBarrel": "@working-theory/templates",
|
|
51
51
|
"sourceRoot": "packages/templates/src/shells",
|
|
52
|
-
"count":
|
|
52
|
+
"count": 6
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"name": "docs-blog-blocks",
|
|
@@ -3015,6 +3015,17 @@
|
|
|
3015
3015
|
"descriptionGrade": "weak",
|
|
3016
3016
|
"sectionType": null
|
|
3017
3017
|
},
|
|
3018
|
+
{
|
|
3019
|
+
"name": "SignedInShell",
|
|
3020
|
+
"tier": "shells",
|
|
3021
|
+
"sourcePath": "packages/templates/src/shells/SignedInShell.tsx",
|
|
3022
|
+
"importPath": "@working-theory/templates/shells",
|
|
3023
|
+
"exported": true,
|
|
3024
|
+
"description": "Every AILK signed-in area runs on this frame: the admin app's workspace (AppFrame supplies its switcher, Library and gating as content) and a site's own /account area.",
|
|
3025
|
+
"descriptionSource": "doc-comment",
|
|
3026
|
+
"descriptionGrade": "strong",
|
|
3027
|
+
"sectionType": null
|
|
3028
|
+
},
|
|
3018
3029
|
{
|
|
3019
3030
|
"name": "DocsActionBar",
|
|
3020
3031
|
"tier": "docs-blog-blocks",
|
package/component-catalog.md
CHANGED
|
@@ -15,13 +15,13 @@ The component inventory of `@working-theory/ui` and the shells of `@working-theo
|
|
|
15
15
|
3. Check the Primitives, Panels and Shells tables too — a low-level control, a collapsible layout container, or a route-level frame lives there, not under Sections or Blocks.
|
|
16
16
|
4. Only then build. If you do build, you are declaring the capability is genuinely absent.
|
|
17
17
|
|
|
18
|
-
Inventory: **
|
|
18
|
+
Inventory: **304 component files** across 16 tiers — 55 sections, 99 blocks, 18 chrome, 79 primitives, 6 panels, 6 shells, 5 docs-blog-blocks, 1 docs-blog-chrome, 2 docs-blog-shells, 2 scheduling-sections, 5 scheduling-blocks, 3 signal-collectors-sections, 8 signal-collectors-blocks, 2 commerce-sections, 12 commerce-blocks, 1 commerce-panels. 301 distinct names: `SchedulerEmbed`, `Separator`, `Tabs` exist in more than one tier as genuinely different components, listed once per tier with its own source path.
|
|
19
19
|
|
|
20
|
-
Description coverage: **
|
|
20
|
+
Description coverage: **300/304** components carry a capability description derived from source — 44 from the section registry's hand-authored when-to-use text, 225 from the component's own doc-comment header, 31 from its tier barrel's roster line. **4** have none; they are listed under [Undescribed components](#undescribed-components) rather than given a sentence invented from their name.
|
|
21
21
|
|
|
22
|
-
Of those, **
|
|
22
|
+
Of those, **269** are *strong* — a capability sentence you could match a requirement against — and **31** are *weak*: either one short clause, or a long one that spends itself on wiring (client boundaries, class names, which primitives it composes) instead of on what the thing is for. Weak rows are marked `(weak)` and listed in full under [Weak descriptions](#weak-descriptions). Coverage is 99%, but **findable-without-luck coverage is 88%**.
|
|
23
23
|
|
|
24
|
-
Per tier, strong out of total: **Sections** 54/55 · **Blocks** 93/99 · **Chrome** 10/18 · **Primitives** 64/79 (4 undescribed) · **Panels** 5/6 · **Shells**
|
|
24
|
+
Per tier, strong out of total: **Sections** 54/55 · **Blocks** 93/99 · **Chrome** 10/18 · **Primitives** 64/79 (4 undescribed) · **Panels** 5/6 · **Shells** 5/6 · **Docs + Blog Blocks** 5/5 · **Docs + Blog Chrome** 1/1 · **Docs + Blog Shells** 0/2 · **Scheduling Sections** 2/2 · **Scheduling Blocks** 5/5 · **Signal-Collectors Sections** 3/3 · **Signal-Collectors Blocks** 7/8 · **Commerce Sections** 2/2 · **Commerce Blocks** 12/12 · **Commerce Panels** 1/1. A tier low on this line is a tier where a capability search is most likely to come up empty on something that exists.
|
|
25
25
|
|
|
26
26
|
That grade is **mechanical**. It counts what it can measure — how much a description adds beyond the component's own name, how much of it is spent on wiring, whether the extraction came out damaged — and it cannot tell a fluent paragraph about the wrong subject from a useful one. A person reading these tables will find more rows unusable than the weak count admits. Treat it as a floor on the problem, not a measurement of it, and read the source before concluding a capability is absent.
|
|
27
27
|
|
|
@@ -365,7 +365,7 @@ Import: `import { X } from "@working-theory/ui/panels";` (or from the `@working-
|
|
|
365
365
|
|
|
366
366
|
---
|
|
367
367
|
|
|
368
|
-
## Shells (
|
|
368
|
+
## Shells (6)
|
|
369
369
|
|
|
370
370
|
Route-level slot-frames — the outermost layout a page is placed into. They supply geometry and slots (nav / sidebar / main / footer), not content: you inject the real Nav, Footer and sidebar. A different package from every tier above: `@working-theory/templates`.
|
|
371
371
|
|
|
@@ -378,6 +378,7 @@ Import: `import { X } from "@working-theory/templates/shells";` (or from the `@w
|
|
|
378
378
|
| `AuthShell` | The frame the five auth pages (/sign-in, /sign-up, /forgot-password, /reset-password, /verify-email) share: a brand mark top-start, one centered content column, and a slim footer. No nav — an auth page has nothing to navigate to, and the marketing Nav is exactly what this shell exists to replace on those routes. | `import { AuthShell } from "@working-theory/templates/shells"` |
|
|
379
379
|
| `DeckHeroObserver` | Internal client island for MarketingShell's `deckHero` option. | internal — compose from `packages/templates/src/shells/DeckHeroObserver.tsx` |
|
|
380
380
|
| `MarketingShell` | Public-facing marketing site **slot-frame**. Server Component (RSC default). A shell is a slot-defining frame: it carves out *where chrome goes* (top + bottom) and *where sections go* (the body), owning **geometry only**. Chrome (Nav/Footer) and sections **fill** the slots — nothing is baked into the component. See (first instance). *(weak)* | `import { MarketingShell } from "@working-theory/templates/shells"` |
|
|
381
|
+
| `SignedInShell` | Every AILK signed-in area runs on this frame: the admin app's workspace (AppFrame supplies its switcher, Library and gating as content) and a site's own /account area. | `import { SignedInShell } from "@working-theory/templates/shells"` |
|
|
381
382
|
|
|
382
383
|
---
|
|
383
384
|
|
package/dist/content-payloads.js
CHANGED
|
@@ -29,8 +29,8 @@ export const CONTENT_PAYLOADS = Object.freeze([
|
|
|
29
29
|
kind: "template-set",
|
|
30
30
|
name: "scaffold-commerce",
|
|
31
31
|
feature: "commerce",
|
|
32
|
-
version: "1.0.
|
|
33
|
-
digest: "
|
|
32
|
+
version: "1.0.1",
|
|
33
|
+
digest: "112704a7910dba3275defc0cb86b1b4f33fc8f262f4c09320e8f6b3a8d873916",
|
|
34
34
|
sourceRoot: "packages/create-ailk/module-templates/commerce/.claude/skills/scaffold-commerce",
|
|
35
35
|
files: Object.freeze([
|
|
36
36
|
"references/lookup-keys-template.md",
|
|
@@ -790,6 +790,15 @@ export const MODULE_SLICES = {
|
|
|
790
790
|
// deliverable by BOTH bundles.
|
|
791
791
|
"apps/api/src/services/inbox-messages.ts",
|
|
792
792
|
"apps/api/src/routes/workspaces/messages.ts",
|
|
793
|
+
// #7399 — the two siblings #6216 left `workspace`-only. They register
|
|
794
|
+
// under that same `workspace || multi-tenant` gate, so a scaffold with
|
|
795
|
+
// `multi-tenant` and without `workspace` loaded them with no file behind
|
|
796
|
+
// the specifier and 500'd every request at server construction. Co-owned
|
|
797
|
+
// with `workspace` (see that slice's entry below); both import only
|
|
798
|
+
// `lib/tenant-db.ts` (owned here) and base files.
|
|
799
|
+
// `__tests__/tenancy-slice-resolution.test.ts` holds the whole gate set.
|
|
800
|
+
"apps/api/src/routes/workspaces/general.ts",
|
|
801
|
+
"apps/api/src/routes/workspaces/members.ts",
|
|
793
802
|
"apps/api/src/lib/tenant-db.ts",
|
|
794
803
|
"apps/api/src/lib/ws-token.ts",
|
|
795
804
|
// #6157 D16 — the magnet Admin authoring surface, co-owned with
|
|
@@ -890,6 +899,8 @@ export const MODULE_SLICES = {
|
|
|
890
899
|
"apps/web/app/[locale]/(authed)/admin/page.tsx",
|
|
891
900
|
"apps/web/app/[locale]/(authed)/admin/site/page.tsx",
|
|
892
901
|
"apps/web/app/[locale]/(authed)/admin/team/page.tsx",
|
|
902
|
+
// Co-owned with `multi-tenant` (#7399) — see that slice's entry: both
|
|
903
|
+
// register under the workspace||multi-tenant gate.
|
|
893
904
|
"apps/api/src/routes/workspaces/general.ts",
|
|
894
905
|
"apps/api/src/routes/workspaces/members.ts",
|
|
895
906
|
// Co-owned with `multi-tenant` (#6216) — see that slice's entry: the
|
package/dist/surfaces.d.ts
CHANGED
|
@@ -162,11 +162,10 @@ export declare function surfaceForRouteDir(routeDir: string): string | undefined
|
|
|
162
162
|
* file only when the target does not already have it — it never overwrites,
|
|
163
163
|
* the same rule routes themselves follow.
|
|
164
164
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* that the waitlist dashboard also uses, rather than duplicating them.
|
|
165
|
+
* Every list is read from the route's own imports, not guessed from issue
|
|
166
|
+
* text. `(authed)/account` no longer reaches into `(authed)/waitlist` (#7430):
|
|
167
|
+
* its layout renders the site's mark through `siteLogo()` on SignedInShell,
|
|
168
|
+
* so `site-logo.tsx` and the `site-brand.tsx` it reads travel with it.
|
|
170
169
|
*/
|
|
171
170
|
export declare const ROUTE_DEPENDENCIES: Readonly<Record<string, readonly string[]>>;
|
|
172
171
|
/**
|
package/dist/surfaces.js
CHANGED
|
@@ -609,6 +609,13 @@ export const ROUTE_SINCE = {
|
|
|
609
609
|
// choice #5048's "projects/apply" entry already made for the same reason.
|
|
610
610
|
"(authed)": "0.18.0",
|
|
611
611
|
"(authed)/account": "0.18.0",
|
|
612
|
+
// #7430 — the base account area's three new route directories (spec 7423
|
|
613
|
+
// U4): item detail, User settings and Support. Genuinely new, so they
|
|
614
|
+
// attach at today's fixed-group version, the same rule #5977, #5678, #5479,
|
|
615
|
+
// #5371 and #5048 applied.
|
|
616
|
+
"(authed)/account/inbox/[kind]/[id]": "0.30.0",
|
|
617
|
+
"(authed)/account/settings": "0.30.0",
|
|
618
|
+
"(authed)/account/support": "0.30.0",
|
|
612
619
|
// The leads inbox (#5678). The ROUTE ships in every scaffold, Free
|
|
613
620
|
// included — lead capture is a never-gate surface, so reading a lead back
|
|
614
621
|
// is one too. Whether it shows a ROW is a separate question with two
|
|
@@ -691,11 +698,10 @@ export function surfaceForRouteDir(routeDir) {
|
|
|
691
698
|
* file only when the target does not already have it — it never overwrites,
|
|
692
699
|
* the same rule routes themselves follow.
|
|
693
700
|
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
* that the waitlist dashboard also uses, rather than duplicating them.
|
|
701
|
+
* Every list is read from the route's own imports, not guessed from issue
|
|
702
|
+
* text. `(authed)/account` no longer reaches into `(authed)/waitlist` (#7430):
|
|
703
|
+
* its layout renders the site's mark through `siteLogo()` on SignedInShell,
|
|
704
|
+
* so `site-logo.tsx` and the `site-brand.tsx` it reads travel with it.
|
|
699
705
|
*/
|
|
700
706
|
export const ROUTE_DEPENDENCIES = {
|
|
701
707
|
// #5963 — the (auth) group's layout renders the brand mark through
|
|
@@ -715,12 +721,45 @@ export const ROUTE_DEPENDENCIES = {
|
|
|
715
721
|
"(auth)/sign-up": ["apps/web/lib/auth-env.ts"],
|
|
716
722
|
"(auth)/forgot-password": ["apps/web/lib/auth-env.ts"],
|
|
717
723
|
"(auth)/reset-password": ["apps/web/lib/auth-env.ts"],
|
|
718
|
-
|
|
724
|
+
// #7430 — the group's layout reads `isAccountPath()` to give the account
|
|
725
|
+
// area its member-facing signed-out gate.
|
|
726
|
+
"(authed)": [
|
|
727
|
+
"apps/web/auth.ts",
|
|
728
|
+
"apps/web/lib/auth-env.ts",
|
|
729
|
+
"apps/web/lib/auth-routes.ts",
|
|
730
|
+
],
|
|
719
731
|
"(authed)/account": [
|
|
720
732
|
"apps/web/lib/account-config.ts",
|
|
721
733
|
"apps/web/lib/server-api.ts",
|
|
722
|
-
|
|
723
|
-
|
|
734
|
+
"apps/web/lib/site-brand.tsx",
|
|
735
|
+
"apps/web/lib/site-logo.tsx",
|
|
736
|
+
],
|
|
737
|
+
// #7430 — the three child routes also import files in their PARENT's
|
|
738
|
+
// directory. A scaffold whose `(authed)/account` predates #7430 keeps its
|
|
739
|
+
// own copy of that route (sync-routes never overwrites), so the files the
|
|
740
|
+
// children need travel with them: each is new in #7430, never a changed
|
|
741
|
+
// file sync-routes could not update.
|
|
742
|
+
"(authed)/account/inbox/[kind]/[id]": [
|
|
743
|
+
"apps/web/lib/account-config.ts",
|
|
744
|
+
"apps/web/lib/server-api.ts",
|
|
745
|
+
`${ROUTE_ROOT}/(authed)/account/account-data.ts`,
|
|
746
|
+
`${ROUTE_ROOT}/(authed)/account/error-row.tsx`,
|
|
747
|
+
`${ROUTE_ROOT}/(authed)/account/retry-button.tsx`,
|
|
748
|
+
],
|
|
749
|
+
"(authed)/account/settings": [
|
|
750
|
+
"apps/web/lib/account-config.ts",
|
|
751
|
+
"apps/web/lib/auth-env.ts",
|
|
752
|
+
"apps/web/lib/server-api.ts",
|
|
753
|
+
"apps/web/lib/site-brand.tsx",
|
|
754
|
+
"apps/web/lib/site-theme.ts",
|
|
755
|
+
`${ROUTE_ROOT}/(authed)/account/account-data.ts`,
|
|
756
|
+
`${ROUTE_ROOT}/(authed)/account/support-actions.ts`,
|
|
757
|
+
],
|
|
758
|
+
"(authed)/account/support": [
|
|
759
|
+
"apps/web/lib/account-config.ts",
|
|
760
|
+
"apps/web/lib/server-api.ts",
|
|
761
|
+
`${ROUTE_ROOT}/(authed)/account/account-data.ts`,
|
|
762
|
+
`${ROUTE_ROOT}/(authed)/account/support-actions.ts`,
|
|
724
763
|
],
|
|
725
764
|
"(authed)/admin": [
|
|
726
765
|
"apps/web/lib/server-api.ts",
|
|
@@ -773,6 +812,9 @@ export const ROUTE_MESSAGE_NAMESPACES = {
|
|
|
773
812
|
"(auth)/reset-password": ["auth"],
|
|
774
813
|
"(auth)/verify-email": ["auth"],
|
|
775
814
|
"(authed)/account": ["account"],
|
|
815
|
+
"(authed)/account/inbox/[kind]/[id]": ["account"],
|
|
816
|
+
"(authed)/account/settings": ["account"],
|
|
817
|
+
"(authed)/account/support": ["account"],
|
|
776
818
|
"(authed)/admin": ["admin"],
|
|
777
819
|
"(authed)/leads": ["leadsInbox"],
|
|
778
820
|
// #5977 — every string on both editor routes comes from the `edit`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-ailk",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.3",
|
|
4
4
|
"description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"tar": "^7.5.21"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@working-theory/schema": "0.
|
|
31
|
-
"@working-theory/templates": "0.
|
|
30
|
+
"@working-theory/schema": "0.31.0",
|
|
31
|
+
"@working-theory/templates": "0.31.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
package/templates/.env.example
CHANGED
|
@@ -59,10 +59,20 @@ CONTENT_ADAPTER=mdx
|
|
|
59
59
|
# Unset in production, either one makes the API log a startup warning; the
|
|
60
60
|
# API keeps running.
|
|
61
61
|
# FRONTEND_URL is the site the browser loads (no trailing slash). Set it on the
|
|
62
|
-
# API project:
|
|
63
|
-
#
|
|
64
|
-
#
|
|
62
|
+
# API project: it is the first trusted origin for CORS, cookie-authenticated
|
|
63
|
+
# writes and checkout redirects. Unset in production, those three trust only
|
|
64
|
+
# AILK_TRUSTED_ORIGINS below (with neither set, they refuse every browser
|
|
65
|
+
# origin and checkout answers 500 to a caller without X-Internal-Auth), and the
|
|
66
|
+
# billing-portal return URLs fall back to http://localhost:3000.
|
|
65
67
|
# FRONTEND_URL=https://your-domain.com
|
|
68
|
+
# AILK_TRUSTED_ORIGINS lists MORE browser origins the API trusts, beside
|
|
69
|
+
# FRONTEND_URL — the Admin app and spoke sites a hub API serves. Comma-separated
|
|
70
|
+
# exact origins, written as a browser sends them: scheme + host (+ port), no
|
|
71
|
+
# path, no trailing slash, lower-case. `*`, wildcard hosts and any other form
|
|
72
|
+
# are refused and the API does not start; production requires https. Each
|
|
73
|
+
# listed origin gets CORS reads, cookie writes and checkout-redirect trust, so
|
|
74
|
+
# list ONLY origins the deployment owner operates — never another party's site.
|
|
75
|
+
# AILK_TRUSTED_ORIGINS=https://admin.your-domain.com,https://spoke.example
|
|
66
76
|
# OSS_API_INTERNAL_SECRET gates POST /v1/checkout/sessions. Unset, the route
|
|
67
77
|
# admits every caller. Set, it answers 401 to any caller that does not send it
|
|
68
78
|
# as X-Internal-Auth — including the MCP start_purchase tool and a browser
|
|
@@ -439,6 +449,35 @@ CONTENT_ADAPTER=mdx
|
|
|
439
449
|
# API is not on localhost.
|
|
440
450
|
# AILK_API_URL=http://localhost:3001
|
|
441
451
|
#
|
|
452
|
+
# Hub and spokes — per-visitor lead limits (#7394). When one apps/api (the
|
|
453
|
+
# hub) is the lead sink for several apps/web sites (spokes) that call it
|
|
454
|
+
# server-to-server through AILK_API_URL, every visitor of a spoke arrives at
|
|
455
|
+
# the hub from the spoke's one egress IP. A spoke holding a hub-issued key
|
|
456
|
+
# forwards the visitor's IP on its three lead writes (contact, newsletter,
|
|
457
|
+
# callback), and the hub counts each visitor separately. Runbook:
|
|
458
|
+
# docs/runbooks/site-configuration.md § Hub and spokes: per-visitor lead
|
|
459
|
+
# limits. All four unset = the behaviour before #7394.
|
|
460
|
+
#
|
|
461
|
+
# HUB (apps/api). One key per spoke, comma-separated; each at least 32
|
|
462
|
+
# characters (mint with `openssl rand -hex 32`). A shorter entry is ignored
|
|
463
|
+
# with a warning naming its position. A key's position identifies its spoke
|
|
464
|
+
# in the rate-limit log: to revoke one, BLANK its entry (`k1,,k3`) rather
|
|
465
|
+
# than deleting it, so no other key changes position.
|
|
466
|
+
# AILK_VISITOR_FORWARD_KEYS=
|
|
467
|
+
#
|
|
468
|
+
# HUB (apps/api). The leads scope's per-visitor limit per minute, and each
|
|
469
|
+
# key's own ceiling per minute across all of its visitors. Whole numbers from
|
|
470
|
+
# 1 to 999999; an invalid value is ignored with a warning. Defaults: 10 and
|
|
471
|
+
# 300.
|
|
472
|
+
# AILK_RATE_LIMIT_LEADS_PER_MINUTE=10
|
|
473
|
+
# AILK_RATE_LIMIT_LEADS_PER_KEY_PER_MINUTE=300
|
|
474
|
+
#
|
|
475
|
+
# SPOKE (apps/web, server-only — never NEXT_PUBLIC_). This spoke's key: one
|
|
476
|
+
# entry of the hub's AILK_VISITOR_FORWARD_KEYS. Sent only when AILK_API_URL
|
|
477
|
+
# is https (or loopback). Issue a key only to a spoke whose platform
|
|
478
|
+
# overwrites x-forwarded-for / x-real-ip with the real client address.
|
|
479
|
+
# AILK_VISITOR_FORWARD_KEY=
|
|
480
|
+
#
|
|
442
481
|
# NEXT_PUBLIC_ENABLED_LOCALES — do NOT set by hand. Baked at build time by
|
|
443
482
|
# apps/web/next.config.mjs from project.yaml `product.locales` (falls back to
|
|
444
483
|
# "en" when project.yaml is absent) and read by middleware.ts, the sitemap
|
|
@@ -15,6 +15,11 @@ PORT=3002
|
|
|
15
15
|
# Frontend URL (for CORS)
|
|
16
16
|
# =============================================================================
|
|
17
17
|
FRONTEND_URL=http://localhost:3000
|
|
18
|
+
# More trusted browser origins (Admin app, spoke sites): comma-separated exact
|
|
19
|
+
# origins, no path or trailing slash; `*` is refused at startup; https in production.
|
|
20
|
+
# Each listed origin gets credentialed CORS, cookie writes and checkout-redirect
|
|
21
|
+
# trust — list only origins the deployment owner operates.
|
|
22
|
+
# AILK_TRUSTED_ORIGINS=https://admin.your-domain.com,https://spoke.example
|
|
18
23
|
|
|
19
24
|
# =============================================================================
|
|
20
25
|
# Local Database (required in production)
|
|
@@ -44,7 +44,7 @@ const DRAFT_PATCH_URL = "/v1/project-listings/drafts/listing_abc123";
|
|
|
44
44
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any avoids pulling in the full FastifyInstance type here
|
|
45
45
|
type App = any;
|
|
46
46
|
|
|
47
|
-
const ENV_KEYS = ["FRONTEND_URL", "NODE_ENV"] as const;
|
|
47
|
+
const ENV_KEYS = ["FRONTEND_URL", "AILK_TRUSTED_ORIGINS", "NODE_ENV"] as const;
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Build a server whose `config` was resolved from `env`. Restores the prior
|
|
@@ -52,6 +52,7 @@ const ENV_KEYS = ["FRONTEND_URL", "NODE_ENV"] as const;
|
|
|
52
52
|
*/
|
|
53
53
|
async function buildWith(env: {
|
|
54
54
|
FRONTEND_URL?: string;
|
|
55
|
+
AILK_TRUSTED_ORIGINS?: string;
|
|
55
56
|
NODE_ENV: string;
|
|
56
57
|
}): Promise<App> {
|
|
57
58
|
for (const key of ENV_KEYS) {
|
|
@@ -147,18 +148,19 @@ describe("CORS preflight (#5397)", () => {
|
|
|
147
148
|
// ACT
|
|
148
149
|
const response = await preflight(app, FOREIGN_ORIGIN, "PATCH");
|
|
149
150
|
|
|
150
|
-
// ASSERT —
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
151
|
+
// ASSERT — the refusal is the browser's: it blocks any response whose
|
|
152
|
+
// allow-origin is not its own origin. So the contract to pin is that the
|
|
153
|
+
// foreign origin is never named back, and the wildcard never appears —
|
|
154
|
+
// `credentials: true` with `*` would let any site read credentialed
|
|
155
|
+
// responses (the security-review M1 posture server.ts already documents).
|
|
156
|
+
// Widening `methods` must not widen WHO may use them. With the ARRAY
|
|
157
|
+
// `origin` option (#7395 D4), @fastify/cors writes no allow-origin header
|
|
158
|
+
// at all for an unlisted origin (D6.4); before #7395 it echoed the one
|
|
159
|
+
// configured string.
|
|
158
160
|
const allowOrigin = response.headers["access-control-allow-origin"];
|
|
159
161
|
expect(allowOrigin).not.toBe(FOREIGN_ORIGIN);
|
|
160
162
|
expect(allowOrigin).not.toBe("*");
|
|
161
|
-
expect(allowOrigin).
|
|
163
|
+
expect(allowOrigin).toBeUndefined();
|
|
162
164
|
});
|
|
163
165
|
|
|
164
166
|
it("advertises every method the registered route table actually serves", async () => {
|
|
@@ -193,3 +195,82 @@ describe("CORS preflight (#5397)", () => {
|
|
|
193
195
|
}
|
|
194
196
|
});
|
|
195
197
|
});
|
|
198
|
+
|
|
199
|
+
// ── #7395 — the trusted-origin allowlist (AC-4) ──────────────────────────────
|
|
200
|
+
//
|
|
201
|
+
// One deployment serves the hub site, its Admin app and every spoke, so CORS
|
|
202
|
+
// answers for each origin the operator listed in FRONTEND_URL or
|
|
203
|
+
// AILK_TRUSTED_ORIGINS, and for no other — in development as in production,
|
|
204
|
+
// since the reflect-any dev arm is gone (D4). A credentialed GET, not only a
|
|
205
|
+
// preflight, carries the header, so a hand-written OPTIONS route cannot pass.
|
|
206
|
+
|
|
207
|
+
describe("CORS trusted-origin allowlist (#7395)", () => {
|
|
208
|
+
const HUB = "https://hub.example";
|
|
209
|
+
const SPOKE_A = "https://spoke-a.example";
|
|
210
|
+
const priorEnv = new Map<string, string | undefined>(
|
|
211
|
+
ENV_KEYS.map((key) => [key, process.env[key]]),
|
|
212
|
+
);
|
|
213
|
+
let app: App;
|
|
214
|
+
|
|
215
|
+
afterEach(async () => {
|
|
216
|
+
if (app) {
|
|
217
|
+
await app.close();
|
|
218
|
+
app = undefined;
|
|
219
|
+
}
|
|
220
|
+
for (const [key, value] of priorEnv) {
|
|
221
|
+
if (value === undefined) {
|
|
222
|
+
delete process.env[key];
|
|
223
|
+
} else {
|
|
224
|
+
process.env[key] = value;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
jest.resetModules();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe.each(["production", "development"])("NODE_ENV=%s", (nodeEnv) => {
|
|
231
|
+
beforeEach(async () => {
|
|
232
|
+
app = await buildWith({
|
|
233
|
+
FRONTEND_URL: HUB,
|
|
234
|
+
AILK_TRUSTED_ORIGINS: SPOKE_A,
|
|
235
|
+
NODE_ENV: nodeEnv,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("allows a PATCH preflight from a listed spoke, with credentials and Vary: Origin", async () => {
|
|
240
|
+
const response = await preflight(app, SPOKE_A, "PATCH");
|
|
241
|
+
|
|
242
|
+
expect(response.headers["access-control-allow-origin"]).toBe(SPOKE_A);
|
|
243
|
+
expect(response.headers["access-control-allow-credentials"]).toBe(
|
|
244
|
+
"true",
|
|
245
|
+
);
|
|
246
|
+
expect(String(response.headers["vary"] ?? "")).toMatch(/\bOrigin\b/i);
|
|
247
|
+
expect(allowedMethods(response)).toContain("PATCH");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("names a listed spoke on a credentialed GET, not only on a preflight", async () => {
|
|
251
|
+
const response = await app.inject({
|
|
252
|
+
method: "GET",
|
|
253
|
+
url: "/health",
|
|
254
|
+
headers: { origin: SPOKE_A, cookie: "better-auth.session_token=x" },
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(response.statusCode).toBe(200);
|
|
258
|
+
expect(response.headers["access-control-allow-origin"]).toBe(SPOKE_A);
|
|
259
|
+
expect(response.headers["access-control-allow-credentials"]).toBe(
|
|
260
|
+
"true",
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("still allows the primary FRONTEND_URL origin", async () => {
|
|
265
|
+
const response = await preflight(app, HUB, "PATCH");
|
|
266
|
+
|
|
267
|
+
expect(response.headers["access-control-allow-origin"]).toBe(HUB);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("writes no allow-origin header for an unlisted origin", async () => {
|
|
271
|
+
const response = await preflight(app, FOREIGN_ORIGIN, "PATCH");
|
|
272
|
+
|
|
273
|
+
expect(response.headers["access-control-allow-origin"]).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file leads-forwarded-rate-limit.test.ts
|
|
3
|
+
* @description #7394 AC-7 — a keyed spoke's lead writes are limited per
|
|
4
|
+
* visitor end to end, through the real `build()` and the REAL
|
|
5
|
+
* `@fastify/rate-limit` global ceiling.
|
|
6
|
+
*
|
|
7
|
+
* `server.test.ts` (and ten other `apps/api` files) replace
|
|
8
|
+
* `@fastify/rate-limit` with a no-op for the whole file, so none of them can
|
|
9
|
+
* observe the global ceiling. This file does not mock it. It mocks only the
|
|
10
|
+
* two boundaries `server.test.ts` mocks for infrastructure reasons:
|
|
11
|
+
* `@fastify/cors` and `@working-theory/email` (an ESM-only transitive dep).
|
|
12
|
+
*
|
|
13
|
+
* `DATABASE_URL` is unset for the file's duration, so a lead write takes the
|
|
14
|
+
* zero-config path, where the route refuses the empty body `{}` with a 400
|
|
15
|
+
* before any write. With the setup file's placeholder `DATABASE_URL`, the
|
|
16
|
+
* route would first try to resolve the site's workspace against a database
|
|
17
|
+
* this test does not have. The limiter runs on `onRequest`, before either.
|
|
18
|
+
*
|
|
19
|
+
* Every request comes from one `x-forwarded-for` — the spoke host. Each
|
|
20
|
+
* sub-case gets a fresh `build()` after `__resetBuckets()`, so neither the
|
|
21
|
+
* in-house buckets nor the plugin's own in-memory store carry over.
|
|
22
|
+
*
|
|
23
|
+
* Spec: docs/specs/7394-the-leads-rate-limit-scope-spec.md §5.1 AC-7.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { __resetBuckets } from "../middleware/rate-limit";
|
|
27
|
+
import { build } from "../server";
|
|
28
|
+
|
|
29
|
+
jest.mock("@fastify/cors", () => {
|
|
30
|
+
return jest.fn().mockResolvedValue(undefined);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
jest.mock("@working-theory/email", () => ({
|
|
34
|
+
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
const KEY = "0123456789abcdef".repeat(4); // gitleaks:allow — test fixture, not a credential (#7394)
|
|
38
|
+
const SPOKE_HOST = "198.51.100.7";
|
|
39
|
+
|
|
40
|
+
/** A distinct, valid visitor IP per index. */
|
|
41
|
+
function visitor(i: number): string {
|
|
42
|
+
return `203.0.${Math.floor(i / 250)}.${(i % 250) + 1}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function headersFor(
|
|
46
|
+
visitorIp: string,
|
|
47
|
+
withKey: boolean,
|
|
48
|
+
): Record<string, string> {
|
|
49
|
+
return {
|
|
50
|
+
"x-forwarded-for": SPOKE_HOST,
|
|
51
|
+
"x-ailk-visitor-ip": visitorIp,
|
|
52
|
+
...(withKey && { "x-ailk-visitor-forward-key": KEY }),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("leads rate limit for a keyed spoke, against the real global ceiling (#7394 AC-7)", () => {
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any matches server.test.ts
|
|
58
|
+
let app: any;
|
|
59
|
+
const prior = process.env.AILK_VISITOR_FORWARD_KEYS;
|
|
60
|
+
const priorDatabaseUrl = process.env.DATABASE_URL;
|
|
61
|
+
|
|
62
|
+
async function statuses(
|
|
63
|
+
count: number,
|
|
64
|
+
request: (i: number) => {
|
|
65
|
+
method: string;
|
|
66
|
+
url: string;
|
|
67
|
+
headers: Record<string, string>;
|
|
68
|
+
payload?: unknown;
|
|
69
|
+
},
|
|
70
|
+
): Promise<number[]> {
|
|
71
|
+
const codes: number[] = [];
|
|
72
|
+
for (let i = 0; i < count; i++) {
|
|
73
|
+
const res = await app.inject(request(i));
|
|
74
|
+
codes.push(res.statusCode);
|
|
75
|
+
}
|
|
76
|
+
return codes;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function leadWrite(visitorIp: string, withKey: boolean) {
|
|
80
|
+
return {
|
|
81
|
+
method: "POST",
|
|
82
|
+
url: "/v1/leads/submit",
|
|
83
|
+
headers: headersFor(visitorIp, withKey),
|
|
84
|
+
payload: {},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
beforeEach(async () => {
|
|
89
|
+
__resetBuckets();
|
|
90
|
+
process.env.AILK_VISITOR_FORWARD_KEYS = KEY;
|
|
91
|
+
delete process.env.DATABASE_URL;
|
|
92
|
+
app = await build();
|
|
93
|
+
await app.ready();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(async () => {
|
|
97
|
+
if (app) await app.close();
|
|
98
|
+
app = undefined;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterAll(() => {
|
|
102
|
+
if (prior === undefined) delete process.env.AILK_VISITOR_FORWARD_KEYS;
|
|
103
|
+
else process.env.AILK_VISITOR_FORWARD_KEYS = prior;
|
|
104
|
+
if (priorDatabaseUrl !== undefined)
|
|
105
|
+
process.env.DATABASE_URL = priorDatabaseUrl;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("(a) 61 keyed lead writes from distinct visitors behind one spoke host: none is 429", async () => {
|
|
109
|
+
const codes = await statuses(61, (i) => leadWrite(visitor(i), true));
|
|
110
|
+
// The route refuses `{}` before any write, so every answer is that 400.
|
|
111
|
+
expect(codes.every((code) => code === 400)).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("(b) 11 keyed lead writes from one visitor: the eleventh is 429", async () => {
|
|
115
|
+
const codes = await statuses(11, () => leadWrite("203.0.113.1", true));
|
|
116
|
+
expect(codes.slice(0, 10).every((code) => code === 400)).toBe(true);
|
|
117
|
+
expect(codes[10]).toBe(429);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("(c) 11 unkeyed lead writes from distinct visitors: the eleventh is 429", async () => {
|
|
121
|
+
const codes = await statuses(11, (i) => leadWrite(visitor(i), false));
|
|
122
|
+
expect(codes.slice(0, 10).every((code) => code === 400)).toBe(true);
|
|
123
|
+
expect(codes[10]).toBe(429);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("(d) 61 keyed GET /health from distinct visitors: the global ceiling still keys on the spoke host", async () => {
|
|
127
|
+
const codes = await statuses(61, (i) => ({
|
|
128
|
+
method: "GET",
|
|
129
|
+
url: "/health",
|
|
130
|
+
headers: headersFor(visitor(i), true),
|
|
131
|
+
}));
|
|
132
|
+
expect(codes[59]).toBe(200);
|
|
133
|
+
expect(codes[60]).toBe(429);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -474,7 +474,8 @@ describe("#6216 D11 — a lead_… path segment is truncated to 8 characters, cs
|
|
|
474
474
|
//
|
|
475
475
|
// POST /v1/checkout/sessions admits a caller without X-Internal-Auth when
|
|
476
476
|
// OSS_API_INTERNAL_SECRET is unset, and with FRONTEND_URL unset its redirect
|
|
477
|
-
// lock answers 500 while
|
|
477
|
+
// lock answers 500 while CORS and cookie writes trust only AILK_TRUSTED_ORIGINS
|
|
478
|
+
// (#7395 D5; before #7395 they pinned to localhost). Both
|
|
478
479
|
// are optional by design (OSS standalone compat), so startup must never fail
|
|
479
480
|
// on them — but a production boot says so, once per variable. Each case rebuilds the module graph because `config/index.ts`
|
|
480
481
|
// snapshots NODE_ENV at import time.
|
|
@@ -545,6 +546,9 @@ describe("#6889 — production startup warns on an unset FRONTEND_URL or OSS_API
|
|
|
545
546
|
});
|
|
546
547
|
expect(naming(messages, "FRONTEND_URL")).toHaveLength(1);
|
|
547
548
|
expect(naming(messages, "OSS_API_INTERNAL_SECRET")).toHaveLength(0);
|
|
549
|
+
// #7395 D5 — nothing is pinned to localhost any more; an unset
|
|
550
|
+
// FRONTEND_URL in production trusts only AILK_TRUSTED_ORIGINS.
|
|
551
|
+
expect(naming(messages, "FRONTEND_URL")[0]).not.toContain("localhost:3000");
|
|
548
552
|
});
|
|
549
553
|
|
|
550
554
|
it("warns exactly once naming OSS_API_INTERNAL_SECRET when it is unset in production", async () => {
|