create-ailk 0.23.0 → 0.24.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 +27 -14
- package/dist/add-module.js +6 -1
- package/dist/add-route.js +7 -1
- package/dist/derive-architecture.js +17 -0
- package/dist/lib/scaffold-render-sweep.d.ts +33 -0
- package/dist/lib/scaffold-render-sweep.js +45 -0
- package/dist/module-architecture.js +17 -0
- package/dist/sync-routes.d.ts +30 -4
- package/dist/sync-routes.js +271 -18
- package/package.json +3 -3
- package/templates/.env.example +4 -0
- package/templates/CLAUDE.md +4 -2
- package/templates/apps/api/.env.example +7 -0
- package/templates/apps/api/CLAUDE.md +25 -11
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +4 -1
- package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
- package/templates/apps/api/src/lib/site.ts +4 -1
- package/templates/apps/api/src/lib/stripe.ts +3 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
- package/templates/apps/api/src/openapi/spec.ts +333 -0
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +18 -11
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +126 -0
- package/templates/apps/api/src/routes/content/get-page.ts +34 -119
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
- package/templates/apps/api/src/routes/content/search.ts +16 -45
- package/templates/apps/api/src/routes/leads/index.ts +364 -298
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +2 -0
- package/templates/apps/api/src/server.ts +25 -0
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +10 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +28 -0
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +6 -0
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
- package/templates/apps/api/src/services/abandon-email.ts +28 -5
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +2 -7
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +53 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +107 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +4 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +4 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +10 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +23 -5
- package/templates/apps/api/src/services/offerings/connection.ts +83 -3
- package/templates/apps/api/src/services/offerings/index.ts +6 -6
- package/templates/apps/api/src/services/person.ts +23 -0
- package/templates/apps/api/src/services/project-listings.ts +43 -31
- package/templates/apps/mcp/CLAUDE.md +11 -5
- package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +91 -10
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
- package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
- package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
- package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
- package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
- package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
- package/templates/apps/mcp/package.json +1 -0
- package/templates/apps/mcp/src/abuse/index.ts +18 -11
- package/templates/apps/mcp/src/http-transport.ts +47 -9
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
- package/templates/apps/mcp/src/lib/site-config.ts +53 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
- package/templates/apps/mcp/src/tools/index.ts +22 -21
- package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
- package/templates/apps/mcp/src/tools/read_content.ts +46 -40
- package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
- package/templates/apps/mcp/src/tools/search_content.ts +20 -24
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
- package/templates/apps/web/CLAUDE.md +12 -3
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +14 -4
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +21 -4
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +7 -0
- package/templates/apps/web/lib/magnet-routes.ts +3 -1
- package/templates/apps/web/lib/newsletter.actions.ts +9 -6
- package/templates/database/CHANGELOG.md +20 -0
- package/templates/database/__tests__/seed.test.ts +33 -1
- package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +27 -0
- package/templates/tools/lint-architecture.ts +205 -1
- package/versions.json +34 -34
- package/templates/apps/api/src/routes/content/slug.ts +0 -9
|
@@ -469,6 +469,29 @@ function usableCustomerId(raw: string | null | undefined): string | null {
|
|
|
469
469
|
: null;
|
|
470
470
|
}
|
|
471
471
|
|
|
472
|
+
/** A Stripe customer id: `cus_` and letters or digits only, no separator. */
|
|
473
|
+
const STRIPE_CUSTOMER_ID = /^cus_[A-Za-z0-9]+$/;
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The Stripe Dashboard link to one customer (#9349 DD5). Pure: the caller
|
|
477
|
+
* reads the mode. `null` unless `customerId` is a well-formed `cus_` id of
|
|
478
|
+
* at most 255 characters, so a stored value can never add a path segment,
|
|
479
|
+
* a query or a fragment to the fixed Dashboard host.
|
|
480
|
+
*/
|
|
481
|
+
export function stripeCustomerDashboardUrl(
|
|
482
|
+
customerId: string,
|
|
483
|
+
mode: "test" | "live",
|
|
484
|
+
): string | null {
|
|
485
|
+
if (
|
|
486
|
+
customerId.length > MAX_CUSTOMER_ID_LENGTH ||
|
|
487
|
+
!STRIPE_CUSTOMER_ID.test(customerId)
|
|
488
|
+
) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
const segment = mode === "test" ? "test/customers" : "customers";
|
|
492
|
+
return `https://dashboard.stripe.com/${segment}/${customerId}`;
|
|
493
|
+
}
|
|
494
|
+
|
|
472
495
|
// ─── findOrCreatePerson ───────────────────────────────────────────────────────
|
|
473
496
|
|
|
474
497
|
/** The one delegate this file writes through — the shared client or a transaction's. */
|
|
@@ -176,6 +176,14 @@ export function deriveListingSlug(name: string): string {
|
|
|
176
176
|
*/
|
|
177
177
|
const SLUG_COLLISION_SCAN_LIMIT = 200;
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* The most active rows the anonymous page-render read returns (#9243). No
|
|
181
|
+
* consumer in this repo names a shelf size, so the bound is the one the other
|
|
182
|
+
* anonymous listing read already carries — the directory's
|
|
183
|
+
* `LISTING_DIRECTORY_MAX_ENTRIES` — rather than a second number to keep in step.
|
|
184
|
+
*/
|
|
185
|
+
const PUBLIC_LISTINGS_MAX_ROWS = LISTING_DIRECTORY_MAX_ENTRIES;
|
|
186
|
+
|
|
179
187
|
/**
|
|
180
188
|
* The first free slug for `base` on this site. Uniqueness is already scoped
|
|
181
189
|
* `@@unique([siteId, slug])`, so the suffix is too: a second "Atlas" on this
|
|
@@ -412,10 +420,10 @@ function toResponseRow(
|
|
|
412
420
|
* Since #5036 D6d the listing stores the experiment's KEY rather than its id,
|
|
413
421
|
* and that key is the same string `WaitlistScore.experimentKey` holds — so the
|
|
414
422
|
* `WaitlistExperiment` read that existed purely to translate one into the
|
|
415
|
-
* other is gone.
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
423
|
+
* other is gone. ONE `WaitlistScore.groupBy` over every DISTINCT key remains
|
|
424
|
+
* (#9243): one read across every returned row (`waitlist-dashboard.ts`'s
|
|
425
|
+
* discipline), however many experiments the page carries — the per-key
|
|
426
|
+
* `count()` fan-out this replaced ran on the anonymous public read.
|
|
419
427
|
*/
|
|
420
428
|
async function attachWaitlistCounts(
|
|
421
429
|
siteId: string,
|
|
@@ -426,29 +434,28 @@ async function attachWaitlistCounts(
|
|
|
426
434
|
];
|
|
427
435
|
if (experimentKeys.length === 0) return new Map();
|
|
428
436
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
);
|
|
451
|
-
return counts;
|
|
437
|
+
// fence: workspaceId === the scope id this query already filters siteId by
|
|
438
|
+
// (workspace_site_agree CHECK, 20260922120000_tenancy_fence). Filtering the
|
|
439
|
+
// CONSTRAINED column makes the scope correct by construction; the format
|
|
440
|
+
// CHECK makes a raw alias match nothing rather than by accident.
|
|
441
|
+
//
|
|
442
|
+
// What that id IS depends on the caller, and only two of the six are
|
|
443
|
+
// route-resolved: `listProjectListings` and `getProjectListing` receive
|
|
444
|
+
// `tenant.workspaceId`, so for them this is an isolation control.
|
|
445
|
+
// `listMyProjectListings` and the two `publicProjectListings` call sites
|
|
446
|
+
// pass a ProjectListing row's OWN `siteId`, so for them it is a
|
|
447
|
+
// CONSISTENCY control — the count agrees with the row it is reported
|
|
448
|
+
// against — exactly as `project-listing-decision.ts#waitlistCountFor`
|
|
449
|
+
// spells out (#6258 § 1.6 / § 5 D8, security review L-2).
|
|
450
|
+
//
|
|
451
|
+
// A key with no score rows is absent from a GROUP BY result; `waitlistCountFor`
|
|
452
|
+
// reads it as 0, the same value the per-key `count()` produced.
|
|
453
|
+
const grouped = await prisma.waitlistScore.groupBy({
|
|
454
|
+
by: ["experimentKey"],
|
|
455
|
+
where: { siteId, workspaceId: siteId, experimentKey: { in: experimentKeys } },
|
|
456
|
+
_count: { _all: true },
|
|
457
|
+
});
|
|
458
|
+
return new Map(grouped.map((g) => [g.experimentKey, g._count._all]));
|
|
452
459
|
}
|
|
453
460
|
|
|
454
461
|
function waitlistCountFor(
|
|
@@ -1902,10 +1909,10 @@ function orderPublicListings<T extends { row: { id: string; decidedAt: Date | nu
|
|
|
1902
1909
|
}
|
|
1903
1910
|
|
|
1904
1911
|
/**
|
|
1905
|
-
* The page-render read (#4998 AC4, reshaped by #5033
|
|
1906
|
-
*
|
|
1907
|
-
* `
|
|
1908
|
-
* this function.
|
|
1912
|
+
* The page-render read (#4998 AC4, reshaped by #5033, bounded by #9243): the
|
|
1913
|
+
* newest-decided `PUBLIC_LISTINGS_MAX_ROWS` `active` rows for the site, the
|
|
1914
|
+
* THREE newest by `decidedAt` first, then the rest by `waitlistCount` desc,
|
|
1915
|
+
* then `decidedAt` desc. `founderEmail` never leaves this function.
|
|
1909
1916
|
*
|
|
1910
1917
|
* Rows whose window has elapsed are returned and read `closed` — computed,
|
|
1911
1918
|
* per row, against one instant, with no job having run. They are not filtered
|
|
@@ -1917,6 +1924,11 @@ export async function publicProjectListings(siteId: string): Promise<ProjectList
|
|
|
1917
1924
|
const listingConfig = await listingConfigFor(siteId);
|
|
1918
1925
|
const rows = await prisma.projectListing.findMany({
|
|
1919
1926
|
where: { siteId, status: "active" },
|
|
1927
|
+
// Same window as the directory read: with a `take`, the rows kept must be
|
|
1928
|
+
// chosen, and "newest decided first, NULLS LAST" is the order the public
|
|
1929
|
+
// shelf leads with anyway.
|
|
1930
|
+
orderBy: [{ decidedAt: { sort: "desc", nulls: "last" } }, { id: "asc" }],
|
|
1931
|
+
take: PUBLIC_LISTINGS_MAX_ROWS,
|
|
1920
1932
|
select: PROJECT_LISTING_SELECT,
|
|
1921
1933
|
});
|
|
1922
1934
|
const counts = await attachWaitlistCounts(siteId, rows);
|
|
@@ -43,8 +43,10 @@ Inherits root `CLAUDE.md`. This file adds MCP-specific rules.
|
|
|
43
43
|
name points straight at the Vercel project; a second proxy in front keys every caller on
|
|
44
44
|
that proxy. It is a function, not `trustProxy: 1`, because Fastify 5.12 treats a numeric
|
|
45
45
|
hop count as trusting nothing. The long-running entry trusts no proxy.
|
|
46
|
-
- **Per address, per instance.** 60 requests a minute across
|
|
47
|
-
|
|
46
|
+
- **Per address, per instance.** 60 requests a minute across every request
|
|
47
|
+
(`MCP_RATE_LIMIT_READ_PER_MIN`), plus a tighter 10 a minute on the six anonymous write tools
|
|
48
|
+
(`MCP_RATE_LIMIT_WRITE_PER_MIN`, #9251), refused with the same 429 shape; `capture_lead` 50 a
|
|
49
|
+
day, `start_purchase` 20 a day,
|
|
48
50
|
`create_booking` 20 a day and `submit_flow_step` 50 a day, each on its own budget.
|
|
49
51
|
`subscribe_newsletter` and `request_callback` have no daily cap. Each warm instance counts
|
|
50
52
|
on its own and a cold start resets the counts, so a caller reaching N instances gets up to
|
|
@@ -73,8 +75,8 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
|
|
|
73
75
|
|
|
74
76
|
**The HTTP write branch (6).** `WRITE_TOOLS` in `src/http-transport.ts` — `capture_lead`, `subscribe_newsletter`, `request_callback`, `start_purchase`, `create_booking`, `submit_flow_step` — takes the write-origin allow-list and the AbuseGuard (UA, honeypot, email format, per-IP daily cap); every other tool takes the read branch (UA only). A tool that sends caller-chosen data to the hub belongs in it: `__tests__/write-tool-guard.test.ts` fails when a catalog tool that sends a non-GET request through `@working-theory/api-client` is missing, and makes every new catalog tool pick a branch (#8071).
|
|
75
77
|
|
|
76
|
-
**Read (9):** `list_pages`, `read_content`, `search_content`, `get_site_info`, `get_business_hours`, `get_locations`, `get_offerings`, `get_pricing`, `get_faq`
|
|
77
|
-
**Conversion-write (3):** `capture_lead`, `subscribe_newsletter`, `request_callback`
|
|
78
|
+
**Read (9):** `list_pages`, `read_content`, `search_content`, `get_site_info`, `get_business_hours`, `get_locations`, `get_offerings`, `get_pricing`, `get_faq`. The first three adopted their `apps/api` routes' arguments and results (#7167, #7168, #7169 — the operator's decision of 2026-10-19): `search_content` takes `q` and answers the route's `ItemList` (`query`, `items`, `totalItems`); `list_pages` requires `type`, takes `page`/`pageSize` and answers the paginated `ItemList` (`items`, `pagination`); `read_content` takes `include` and answers the page's JSON-LD at the root with `page`, plus `body` only with `include: "body"` — a miss is the not-found body. Each parses the same `@working-theory/validation` export its route parses with and answers through the same `@working-theory/content-adapters` builder, so the parity guard holds them as `twin`s
|
|
79
|
+
**Conversion-write (3):** `capture_lead`, `subscribe_newsletter`, `request_callback` — each takes the body its `POST /v1/leads/{submit,subscribe,callback}` route parses (`leadSubmitBodySchema`: `source`, `email`, `consent`, `payload`; `leadSubscribeBodySchema`: `email`, `source`; `leadCallbackBodySchema`: `name`, `phone`, `preferredTime`, `payload`) and returns the route's 201 body `{ id, persisted, email }` unchanged (#7171, #7172, #7173 — the operator's decision of 2026-10-19), so the parity guard holds the three as `twin`s. `source` is the agent's to choose — `mcp` unless the operator says otherwise — and a consent-bearing source needs `consent: true`, exactly as a form does; `capture_lead` still spreads the spoke marker `payload.site` from `AILK_LEAD_SITE` last. The AbuseGuard reads the first two's `email` at the top level, else inside `payload` (the route lifts it from there), so a malformed address is refused wherever it sits
|
|
78
80
|
**Maintenance (2):** `audit`, `create_project`
|
|
79
81
|
**Flow (2):** `get_flow`, `submit_flow_step` — module-gated (`module: "signal-collectors"`, D-48, #5366) and licence-feature-tagged (`feature: "flows-deliverables"`, #7136): identity-blind like every tool in this list, registered only when the signal-collectors module is present, and gated on the deployment's `flows-deliverables` licence feature under `MCP_FEATURE_GATE_MODE`, the same shape Booking below has. `submit_flow_step` takes the HTTP write branch (its own daily cap, `MCP_ABUSE_SUBMIT_FLOW_STEP_DAILY_CAP`, default 50 — #8071)
|
|
80
82
|
**Booking (4):** `list_availability`, `get_event_meta`, `list_meetings`, `create_booking` — module-gated (`module: "scheduling"`) and licence-feature-tagged (`feature: "scheduling"`, #7136), gated on the deployment's `scheduling` licence feature under `MCP_FEATURE_GATE_MODE`. `create_booking` takes the HTTP write branch (its own daily cap, `MCP_ABUSE_CREATE_BOOKING_DAILY_CAP`, default 20, and an `attendee.email` format check — #8071). `list_meetings` (#8843 D5) twins `GET /v1/schedule/meetings`: it takes no argument and returns the site's Live meetings — slug, visibility, position and whether each can be booked, plus a bookable one's calLink, title, length, description and price; `private` means unlisted, not secret
|
|
@@ -223,7 +225,11 @@ Example (lead routing):
|
|
|
223
225
|
- Don't add a tool without the matching `apps/api` endpoint.
|
|
224
226
|
- Don't put business logic in tool handlers — they're adapters.
|
|
225
227
|
|
|
226
|
-
Last updated: 2026-10-
|
|
228
|
+
Last updated: 2026-10-20 — #7171, #7172, #7173: the three lead writes adopted their routes' bodies and 201 results and are parity `twin`s — the Conversion-write line records the adopted shapes — verified against `apps/mcp/src/tools/{capture_lead,subscribe_newsletter,request_callback}.ts`, `packages/validation/src/schemas/leads.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
229
|
+
|
|
230
|
+
Prior: 2026-10-20 — #7167, #7168, #7169: the three content reads adopted their routes' arguments and results and are parity `twin`s — the Read line records the adopted shapes — verified against `apps/mcp/src/tools/{search_content,list_pages,read_content}.ts`, `packages/validation/src/schemas/content-reads.ts`, `packages/content-adapters/src/content-reads.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
231
|
+
|
|
232
|
+
Prior: 2026-10-18 — #9137: the three listing application tools are `list_listing_applications`, `get_listing_application` and `decide_listing_application`, and their pre-rename names stay one release as deprecated aliases — the signed-in workspace surface is eighteen tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `apps/mcp/src/workspace-server.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
227
233
|
|
|
228
234
|
Prior: 2026-10-18 — #8843 PR A: `list_meetings` twins `GET /v1/schedule/meetings` — Booking is four tools, the counts are 21 in TOOLS, 20 registered keyless and 21 commerce-claimed, and six tools carry `tier: "pro"` — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `apps/mcp/src/server.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
229
235
|
|
|
@@ -134,9 +134,9 @@ function captureLead(id: number, name: string): object {
|
|
|
134
134
|
params: {
|
|
135
135
|
name: "capture_lead",
|
|
136
136
|
arguments: {
|
|
137
|
-
|
|
137
|
+
source: "mcp",
|
|
138
138
|
email: `${name.toLowerCase()}@example.com`,
|
|
139
|
-
message: "Interested in your services.",
|
|
139
|
+
payload: { name, message: "Interested in your services." },
|
|
140
140
|
},
|
|
141
141
|
},
|
|
142
142
|
};
|
|
@@ -185,8 +185,9 @@ describe("CORS enforcement on write tools", () => {
|
|
|
185
185
|
params: {
|
|
186
186
|
name: "capture_lead",
|
|
187
187
|
arguments: {
|
|
188
|
-
|
|
188
|
+
source: "mcp",
|
|
189
189
|
email: "bot@evil.com",
|
|
190
|
+
payload: { name: "Bot" },
|
|
190
191
|
},
|
|
191
192
|
},
|
|
192
193
|
};
|
|
@@ -214,8 +215,9 @@ describe("CORS enforcement on write tools", () => {
|
|
|
214
215
|
params: {
|
|
215
216
|
name: "capture_lead",
|
|
216
217
|
arguments: {
|
|
217
|
-
|
|
218
|
+
source: "mcp",
|
|
218
219
|
email: "jane@example.com",
|
|
220
|
+
payload: { name: "Jane" },
|
|
219
221
|
},
|
|
220
222
|
},
|
|
221
223
|
};
|
|
@@ -297,8 +299,31 @@ describe("Abuse rules", () => {
|
|
|
297
299
|
params: {
|
|
298
300
|
name: "capture_lead",
|
|
299
301
|
arguments: {
|
|
300
|
-
|
|
302
|
+
source: "mcp",
|
|
301
303
|
email: "not-an-email",
|
|
304
|
+
payload: { name: "Spammer" },
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const res = await mcpPost(port, payload);
|
|
310
|
+
expect(res.status).toBe(422);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// #7171 — `email` is optional at the top level of the route body (the
|
|
314
|
+
// site's own envelope carries it inside `payload`, and the route lifts it
|
|
315
|
+
// from there), so the guard reads `payload.email` when the top level has
|
|
316
|
+
// none: a malformed address cannot be nested past the check.
|
|
317
|
+
it("rejects capture_lead with a malformed payload.email and no top-level email", async () => {
|
|
318
|
+
const payload = {
|
|
319
|
+
jsonrpc: "2.0",
|
|
320
|
+
id: 23,
|
|
321
|
+
method: "tools/call",
|
|
322
|
+
params: {
|
|
323
|
+
name: "capture_lead",
|
|
324
|
+
arguments: {
|
|
325
|
+
source: "mcp",
|
|
326
|
+
payload: { name: "Spammer", email: "not-an-email" },
|
|
302
327
|
},
|
|
303
328
|
},
|
|
304
329
|
};
|
|
@@ -315,9 +340,10 @@ describe("Abuse rules", () => {
|
|
|
315
340
|
params: {
|
|
316
341
|
name: "capture_lead",
|
|
317
342
|
arguments: {
|
|
318
|
-
|
|
343
|
+
source: "mcp",
|
|
319
344
|
email: "bot@example.com",
|
|
320
345
|
honeypot: "i-am-a-bot",
|
|
346
|
+
payload: { name: "Bot" },
|
|
321
347
|
},
|
|
322
348
|
},
|
|
323
349
|
};
|
|
@@ -346,7 +372,11 @@ describe("Abuse rules", () => {
|
|
|
346
372
|
method: "tools/call",
|
|
347
373
|
params: {
|
|
348
374
|
name: "capture_lead",
|
|
349
|
-
arguments: {
|
|
375
|
+
arguments: {
|
|
376
|
+
source: "mcp",
|
|
377
|
+
email: "bot@example.com",
|
|
378
|
+
payload: { name: "Bot" },
|
|
379
|
+
},
|
|
350
380
|
},
|
|
351
381
|
},
|
|
352
382
|
{ "user-agent": "EvilBot/1.0" },
|
|
@@ -373,7 +403,14 @@ describe("capture_lead forwards the trusted caller address (#8073)", () => {
|
|
|
373
403
|
beforeEach(async () => {
|
|
374
404
|
submitSpy = jest
|
|
375
405
|
.spyOn(apiClient.leads, "submit")
|
|
376
|
-
.mockResolvedValue({
|
|
406
|
+
.mockResolvedValue({
|
|
407
|
+
ok: true,
|
|
408
|
+
body: {
|
|
409
|
+
id: "lead_1",
|
|
410
|
+
persisted: "db",
|
|
411
|
+
email: { ok: true, messageId: "msg_1" },
|
|
412
|
+
},
|
|
413
|
+
});
|
|
377
414
|
app = await buildHttpTransportApp(DEFAULT_CONFIG);
|
|
378
415
|
await app.listen({ host: "127.0.0.1", port: 0 });
|
|
379
416
|
port = (app.server.address() as AddressInfo).port;
|
|
@@ -390,7 +427,10 @@ describe("capture_lead forwards the trusted caller address (#8073)", () => {
|
|
|
390
427
|
jsonrpc: "2.0",
|
|
391
428
|
id,
|
|
392
429
|
method: "tools/call",
|
|
393
|
-
params: {
|
|
430
|
+
params: {
|
|
431
|
+
name: "capture_lead",
|
|
432
|
+
arguments: { source: "mcp", email, payload: { name: "Jane" } },
|
|
433
|
+
},
|
|
394
434
|
};
|
|
395
435
|
}
|
|
396
436
|
|
|
@@ -411,6 +451,36 @@ describe("capture_lead forwards the trusted caller address (#8073)", () => {
|
|
|
411
451
|
});
|
|
412
452
|
});
|
|
413
453
|
|
|
454
|
+
// #7171 AC-11, AC-14 — the body reaches the hub as the agent sent it; an
|
|
455
|
+
// agent's `siteId` is STRIPPED by the SDK's parse against the registered
|
|
456
|
+
// shape (never refused, as the route strips it), so none is ever posted.
|
|
457
|
+
it("posts the route body as sent, and strips an agent's siteId rather than refusing it", async () => {
|
|
458
|
+
delete process.env.AILK_LEAD_SITE;
|
|
459
|
+
|
|
460
|
+
const res = await mcpPost(port, {
|
|
461
|
+
jsonrpc: "2.0",
|
|
462
|
+
id: 42,
|
|
463
|
+
method: "tools/call",
|
|
464
|
+
params: {
|
|
465
|
+
name: "capture_lead",
|
|
466
|
+
arguments: {
|
|
467
|
+
source: "mcp",
|
|
468
|
+
email: "jane3@example.com",
|
|
469
|
+
payload: { name: "Jane" },
|
|
470
|
+
siteId: "evil",
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
expect(res.status).toBe(200);
|
|
476
|
+
expect(submitSpy).toHaveBeenCalledTimes(1);
|
|
477
|
+
expect(submitSpy.mock.calls[0]?.[0]).toEqual({
|
|
478
|
+
source: "mcp",
|
|
479
|
+
email: "jane3@example.com",
|
|
480
|
+
payload: { name: "Jane" },
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
414
484
|
it("sends no visitor headers (and no second argument) when the key is unset", async () => {
|
|
415
485
|
delete process.env.AILK_VISITOR_FORWARD_KEY;
|
|
416
486
|
|
|
@@ -646,7 +716,7 @@ describe("JSON-RPC batch bodies take the write branch (#5971)", () => {
|
|
|
646
716
|
method: "tools/call",
|
|
647
717
|
params: {
|
|
648
718
|
name: "capture_lead",
|
|
649
|
-
arguments: {
|
|
719
|
+
arguments: { source: "mcp", email: "a@example.com", payload: { name: "A" } },
|
|
650
720
|
},
|
|
651
721
|
});
|
|
652
722
|
|
|
@@ -752,6 +822,9 @@ describe("a non-finite daily cap falls back to the default, never to unlimited (
|
|
|
752
822
|
});
|
|
753
823
|
app = await buildHttpTransportApp({
|
|
754
824
|
...DEFAULT_CONFIG,
|
|
825
|
+
// 21 writes in one minute: lift the per-minute write bucket (#9251) so
|
|
826
|
+
// the DAILY cap is the limit this test exercises.
|
|
827
|
+
rateLimits: { readPerMin: 60, writePerMin: 1000 },
|
|
755
828
|
cors: { writeAllowedOrigins: ["*"] as string[] },
|
|
756
829
|
abuse: {
|
|
757
830
|
captureLeadDailyCapPerIp: 50,
|
|
@@ -855,12 +928,20 @@ describe("abuse refusal status is keyed on the typed reason code (#5994)", () =>
|
|
|
855
928
|
// the preHandler, is under test.
|
|
856
929
|
await mcpPost(
|
|
857
930
|
port,
|
|
858
|
-
captureLeadPayload(40, {
|
|
931
|
+
captureLeadPayload(40, {
|
|
932
|
+
source: "mcp",
|
|
933
|
+
email: "first@example.com",
|
|
934
|
+
payload: { name: "First" },
|
|
935
|
+
}),
|
|
859
936
|
);
|
|
860
937
|
|
|
861
938
|
const capped = await mcpPost(
|
|
862
939
|
port,
|
|
863
|
-
captureLeadPayload(41, {
|
|
940
|
+
captureLeadPayload(41, {
|
|
941
|
+
source: "mcp",
|
|
942
|
+
email: "second@example.com",
|
|
943
|
+
payload: { name: "Second" },
|
|
944
|
+
}),
|
|
864
945
|
);
|
|
865
946
|
|
|
866
947
|
expect(capped.status).toBe(403);
|
|
@@ -57,9 +57,14 @@ describe("read_content ↔ apps/api adapter parity (CONTENT_ADAPTER=mdx)", () =>
|
|
|
57
57
|
});
|
|
58
58
|
const viaApi = await apiAdapter.getPageBySlug("HomePage", "home", locale);
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
// #7169: the tool answers the route's document — the frontmatter sits
|
|
61
|
+
// under `page` (a HomePage document's root is `@context`/`@graph`/`page`),
|
|
62
|
+
// and a miss is the not-found body rather than `null`.
|
|
63
|
+
if (!("page" in viaMcp)) {
|
|
64
|
+
throw new Error(`read_content answered ${JSON.stringify(viaMcp)}`);
|
|
65
|
+
}
|
|
61
66
|
expect(viaApi).not.toBeNull();
|
|
62
|
-
expect(viaMcp
|
|
63
|
-
expect(viaMcp
|
|
67
|
+
expect(viaMcp.page.slug).toBe(viaApi!.slug);
|
|
68
|
+
expect(viaMcp.page.title).toBe(viaApi!.title);
|
|
64
69
|
});
|
|
65
70
|
});
|
|
@@ -12,9 +12,6 @@
|
|
|
12
12
|
* - it forwards the caller's resolved `locale` as `defaultLocale`;
|
|
13
13
|
* - it defaults `contentRoot` to `mcpContentRoot()` when the caller passes
|
|
14
14
|
* none (#8070 D3; the resolver's order is pinned in `content-root.test.ts`);
|
|
15
|
-
* - its MDX-narrowed twin `getMcpMdxContentAdapter()` (which `read_content`
|
|
16
|
-
* needs for `getPageSource()`) resolves the same way and raises a named
|
|
17
|
-
* error rather than a TypeError when the selected adapter is not MDX;
|
|
18
15
|
* - on `CONTENT_ADAPTER=mdx` (the default, unset), it passes NO `workspaceId`;
|
|
19
16
|
* - on `CONTENT_ADAPTER=database`, it reads `mcpPublicWorkspace()` (#5876
|
|
20
17
|
* spec D2/D4/D5/D6): a resolved workspace forwards `workspaceId` + `prisma`
|
|
@@ -34,7 +31,6 @@ import type { ArtifactPrismaClient } from "@working-theory/content-adapters";
|
|
|
34
31
|
|
|
35
32
|
import {
|
|
36
33
|
getMcpContentAdapter,
|
|
37
|
-
getMcpMdxContentAdapter,
|
|
38
34
|
mcpContentRoot,
|
|
39
35
|
} from "../../src/lib/content-adapter.js";
|
|
40
36
|
import {
|
|
@@ -164,24 +160,3 @@ describe("getMcpContentAdapter — CONTENT_ADAPTER=database", () => {
|
|
|
164
160
|
await expect(adapter.getPageBySlug("HomePage", "home")).resolves.toBeNull();
|
|
165
161
|
});
|
|
166
162
|
});
|
|
167
|
-
|
|
168
|
-
describe("getMcpMdxContentAdapter", () => {
|
|
169
|
-
beforeEach(() => {
|
|
170
|
-
mockResolveContentAdapter.mockReset();
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it("returns the resolved adapter when it exposes getPageSource", () => {
|
|
174
|
-
const mdxish = { getPageSource: jest.fn() };
|
|
175
|
-
mockResolveContentAdapter.mockReturnValue(mdxish);
|
|
176
|
-
|
|
177
|
-
expect(getMcpMdxContentAdapter({ contentRoot: "/fixtures/content" })).toBe(
|
|
178
|
-
mdxish,
|
|
179
|
-
);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("throws a named error when the resolved adapter is not MDX-capable", () => {
|
|
183
|
-
mockResolveContentAdapter.mockReturnValue({ getPageBySlug: jest.fn() });
|
|
184
|
-
|
|
185
|
-
expect(() => getMcpMdxContentAdapter()).toThrow(/CONTENT_ADAPTER=mdx/);
|
|
186
|
-
});
|
|
187
|
-
});
|
|
@@ -78,21 +78,36 @@ describe("every content tool — CONTENT_ADAPTER=database, unresolved workspace"
|
|
|
78
78
|
expect(stub.artifactVersion.findMany).not.toHaveBeenCalled();
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
// #7167, #7168, #7169 (the operator's decision of 2026-10-19): the three
|
|
82
|
+
// content reads answer their routes' shapes — the MCP not-found body for a
|
|
83
|
+
// missing page, and the routes' empty ItemLists — still with no query.
|
|
84
|
+
it("ailkReadContent returns the not-found body", async () => {
|
|
82
85
|
const result = await ailkReadContent({ type: "HomePage", slug: "home" });
|
|
83
|
-
expect(result).
|
|
86
|
+
expect(result).toEqual({ error: "not_found", message: "Page not found" });
|
|
84
87
|
expectNoQuery();
|
|
85
88
|
});
|
|
86
89
|
|
|
87
|
-
it("ailkListPages returns
|
|
90
|
+
it("ailkListPages returns the empty paginated ItemList", async () => {
|
|
88
91
|
const result = await ailkListPages({ type: "HomePage" });
|
|
89
|
-
expect(result).toEqual({
|
|
92
|
+
expect(result).toEqual({
|
|
93
|
+
"@context": "https://schema.org",
|
|
94
|
+
"@type": "ItemList",
|
|
95
|
+
type: "HomePage",
|
|
96
|
+
items: [],
|
|
97
|
+
pagination: { page: 1, pageSize: 20, totalItems: 0, totalPages: 1 },
|
|
98
|
+
});
|
|
90
99
|
expectNoQuery();
|
|
91
100
|
});
|
|
92
101
|
|
|
93
|
-
it("ailkSearchContent returns
|
|
94
|
-
const result = await ailkSearchContent({
|
|
95
|
-
expect(result).toEqual({
|
|
102
|
+
it("ailkSearchContent returns the empty ItemList", async () => {
|
|
103
|
+
const result = await ailkSearchContent({ q: "anything" });
|
|
104
|
+
expect(result).toEqual({
|
|
105
|
+
"@context": "https://schema.org",
|
|
106
|
+
"@type": "ItemList",
|
|
107
|
+
query: "anything",
|
|
108
|
+
items: [],
|
|
109
|
+
totalItems: 0,
|
|
110
|
+
});
|
|
96
111
|
expectNoQuery();
|
|
97
112
|
});
|
|
98
113
|
|