create-ailk 0.16.0 → 0.18.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/dist/lib/env-catalog.js +5 -9
- package/dist/spoke-checkout.d.ts +27 -2
- package/dist/spoke-checkout.js +207 -4
- package/dist/topology.d.ts +8 -0
- package/dist/topology.js +29 -5
- package/package.json +3 -3
- package/templates/apps/mcp/CLAUDE.md +51 -5
- package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
- package/templates/apps/mcp/__tests__/boot.test.ts +203 -0
- package/templates/apps/mcp/__tests__/index.test.ts +86 -47
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
- package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
- package/templates/apps/mcp/__tests__/server.test.ts +63 -6
- package/templates/apps/mcp/src/app.ts +32 -0
- package/templates/apps/mcp/src/boot.ts +185 -0
- package/templates/apps/mcp/src/http-transport.ts +39 -15
- package/templates/apps/mcp/src/index.ts +17 -126
- package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
- package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
- package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
- package/templates/apps/mcp/src/server.ts +10 -2
- package/templates/apps/mcp/src/serverless-app.ts +88 -0
- package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
- package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
- package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
- package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
- package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
- package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
- package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
- package/templates/apps/mcp/src/tools/read_content.ts +7 -7
- package/templates/apps/mcp/src/tools/search_content.ts +6 -5
- package/templates/apps/mcp/tsconfig.json +2 -0
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +18 -4
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +46 -28
- package/templates/apps/web/lib/__tests__/relay.test.ts +13 -7
- package/templates/apps/web/lib/__tests__/server-api.test.ts +37 -28
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
- package/templates/apps/web/lib/site-brand.tsx +22 -6
- package/templates/apps/web/lib/topology.generated.ts +3 -0
- package/versions.json +1 -1
package/dist/lib/env-catalog.js
CHANGED
|
@@ -24,7 +24,7 @@ export const ENV_CATALOG = {
|
|
|
24
24
|
secret: false,
|
|
25
25
|
},
|
|
26
26
|
AILK_CONTENT_ROOT: {
|
|
27
|
-
description: "Overrides the on-disk content root; defaults to <repo-root>/content.",
|
|
27
|
+
description: "Overrides the on-disk content root (absolute, or relative to the working directory). Unset, the MCP server tries <cwd>/content, then <cwd>/../../content; the web app defaults to <repo-root>/content.",
|
|
28
28
|
secret: false,
|
|
29
29
|
},
|
|
30
30
|
AILK_ENABLED_MODULES: {
|
|
@@ -143,16 +143,12 @@ export const ENV_CATALOG = {
|
|
|
143
143
|
description: "The port the MCP HTTP transport binds; defaults to 8080.",
|
|
144
144
|
secret: false,
|
|
145
145
|
},
|
|
146
|
-
|
|
147
|
-
description: "
|
|
148
|
-
secret: false,
|
|
149
|
-
},
|
|
150
|
-
MCP_RATE_LIMIT_READ_PER_MIN: {
|
|
151
|
-
description: "Maximum MCP read-tool requests per minute per IP; defaults to 60.",
|
|
146
|
+
MCP_HTTP_STATELESS: {
|
|
147
|
+
description: 'Set to "true" or "1" for an MCP HTTP transport with no sessions, which any instance can answer; "false" or "0" keeps sessions in one process. Unset means stateless on Vercel (src/app.ts) and stateful on a port (src/index.ts).',
|
|
152
148
|
secret: false,
|
|
153
149
|
},
|
|
154
|
-
|
|
155
|
-
description: "
|
|
150
|
+
MCP_PUBLIC_HTTP_URL: {
|
|
151
|
+
description: "The public URL of the MCP HTTP transport, advertised by /.well-known/mcp.json.",
|
|
156
152
|
secret: false,
|
|
157
153
|
},
|
|
158
154
|
MCP_TRANSPORT: {
|
package/dist/spoke-checkout.d.ts
CHANGED
|
@@ -24,15 +24,40 @@ export declare function spokeEnvRowOptions(commerce: boolean): Pick<PlanEnvRowsO
|
|
|
24
24
|
* `site-` and the first 8 hex characters of its SHA-256. Never `AILK_SITE_ID`.
|
|
25
25
|
*/
|
|
26
26
|
export declare function spokeCheckoutSite(projectName: string): string;
|
|
27
|
+
/** Where a commerce spoke's end-to-end checkout workflow lives. */
|
|
28
|
+
export declare const SPOKE_CHECKOUT_E2E_WORKFLOW_PATH = ".github/workflows/checkout-e2e.yml";
|
|
29
|
+
/**
|
|
30
|
+
* The Stripe CLI release the workflow installs, and the SHA-256 of its
|
|
31
|
+
* `stripe_<v>_linux_x86_64.tar.gz` from that release's own
|
|
32
|
+
* `stripe-linux-checksums.txt`. Never bump one without re-reading the other.
|
|
33
|
+
*/
|
|
34
|
+
export declare const STRIPE_CLI_VERSION = "1.51.1";
|
|
35
|
+
export declare const STRIPE_CLI_LINUX_X64_SHA256 = "38c8de73a8cab810a4aab8a4015cd4e3398b7b87aa42b6944d09d89ed524ac53";
|
|
36
|
+
/** The Playwright the workflow installs — the monorepo's own pinned version. */
|
|
37
|
+
export declare const E2E_PLAYWRIGHT_VERSION = "1.61.1";
|
|
38
|
+
/**
|
|
39
|
+
* Every action the workflow uses, pinned to the commit its `v4` tag named
|
|
40
|
+
* when this was written (SEC-7839-2): `gh api repos/<action>/commits/v4`.
|
|
41
|
+
*/
|
|
42
|
+
export declare const E2E_ACTION_PINS: Readonly<Record<string, string>>;
|
|
43
|
+
/**
|
|
44
|
+
* The workflow text (D8). Identical for every spoke at one create-ailk
|
|
45
|
+
* version. Every `${{ … }}` sits in an env-style value, never in a script.
|
|
46
|
+
*/
|
|
47
|
+
export declare const CHECKOUT_E2E_WORKFLOW: string;
|
|
27
48
|
/**
|
|
28
49
|
* The slice for `site`, in order: the two route files, the config, the two
|
|
29
|
-
* pages (D3)
|
|
50
|
+
* pages (D3), then the checkout e2e workflow (#7839 D8), which does not
|
|
51
|
+
* depend on `site`. Throws `TopologyError` for an invalid stamp (SEC-7838-10).
|
|
30
52
|
*/
|
|
31
53
|
export declare function renderSpokeCheckoutFiles(site: string): [string, string][];
|
|
32
54
|
/**
|
|
33
55
|
* Write the slice into `targetDir` (D3). A file that already exists is left
|
|
34
56
|
* byte-for-byte and reported `skipped` — the config is the site's once
|
|
35
|
-
* written. Adds the package to `apps/web/package.json`
|
|
57
|
+
* written. Adds the package to `apps/web/package.json` when absent, and
|
|
58
|
+
* raises a plain pin older than the one it writes (#7839): the generated
|
|
59
|
+
* checkout e2e workflow runs a command a `^0.2.0` pin never reaches. Any
|
|
60
|
+
* other range — newer, `workspace:`, a tag — is the site's and is kept.
|
|
36
61
|
* Every refusal (an invalid stamp, a version map without the package, a
|
|
37
62
|
* missing or malformed `apps/web/package.json`) throws before the first write.
|
|
38
63
|
*/
|
package/dist/spoke-checkout.js
CHANGED
|
@@ -286,9 +286,183 @@ const UNAVAILABLE_PAGE = renderPage({
|
|
|
286
286
|
"},",
|
|
287
287
|
],
|
|
288
288
|
});
|
|
289
|
+
// ── #7839 D8 — the checkout e2e workflow ─────────────────────────────────────
|
|
290
|
+
/** Where a commerce spoke's end-to-end checkout workflow lives. */
|
|
291
|
+
export const SPOKE_CHECKOUT_E2E_WORKFLOW_PATH = ".github/workflows/checkout-e2e.yml";
|
|
292
|
+
/**
|
|
293
|
+
* The Stripe CLI release the workflow installs, and the SHA-256 of its
|
|
294
|
+
* `stripe_<v>_linux_x86_64.tar.gz` from that release's own
|
|
295
|
+
* `stripe-linux-checksums.txt`. Never bump one without re-reading the other.
|
|
296
|
+
*/
|
|
297
|
+
export const STRIPE_CLI_VERSION = "1.51.1";
|
|
298
|
+
export const STRIPE_CLI_LINUX_X64_SHA256 = "38c8de73a8cab810a4aab8a4015cd4e3398b7b87aa42b6944d09d89ed524ac53";
|
|
299
|
+
/** The Playwright the workflow installs — the monorepo's own pinned version. */
|
|
300
|
+
export const E2E_PLAYWRIGHT_VERSION = "1.61.1";
|
|
301
|
+
/**
|
|
302
|
+
* Every action the workflow uses, pinned to the commit its `v4` tag named
|
|
303
|
+
* when this was written (SEC-7839-2): `gh api repos/<action>/commits/v4`.
|
|
304
|
+
*/
|
|
305
|
+
export const E2E_ACTION_PINS = Object.freeze({
|
|
306
|
+
"actions/checkout": "11d5960a326750d5838078e36cf38b85af677262",
|
|
307
|
+
"pnpm/action-setup": "b906affcce14559ad1aafd4ab0e942779e9f58b1",
|
|
308
|
+
"actions/setup-node": "49933ea5288caeca8642d1e84afbd3f7d6820020",
|
|
309
|
+
"actions/upload-artifact": "ea165f8d65b6e75b540449e92b4886f43607fa02",
|
|
310
|
+
});
|
|
311
|
+
function uses(action) {
|
|
312
|
+
return `uses: ${action}@${E2E_ACTION_PINS[action]} # v4`;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* The workflow text (D8). Identical for every spoke at one create-ailk
|
|
316
|
+
* version. Every `${{ … }}` sits in an env-style value, never in a script.
|
|
317
|
+
*/
|
|
318
|
+
export const CHECKOUT_E2E_WORKFLOW = `# Checkout e2e — GENERATED by create-ailk (#7839). Do not edit: it changes
|
|
319
|
+
# with the ${SPOKE_CHECKOUT_PACKAGE} command it runs.
|
|
320
|
+
#
|
|
321
|
+
# Pays each named offering once through Stripe's hosted Checkout in TEST
|
|
322
|
+
# mode, then checks Stripe's webhook was answered 2xx and this site's
|
|
323
|
+
# fulfilment ran. Start it by hand (Actions > Checkout e2e > Run workflow)
|
|
324
|
+
# after a checkout change and before any live flip. It never runs on a push
|
|
325
|
+
# or a pull request: it needs the network, the Stripe CLI and test-mode state.
|
|
326
|
+
#
|
|
327
|
+
# Setup, once: create the GitHub Environment "checkout-e2e", limit its
|
|
328
|
+
# deployment branches to the default branch, and add to it the secrets
|
|
329
|
+
# STRIPE_TEST_SECRET_KEY (an sk_test_ or rk_test_ key) and AILK_SPOKE_KEY,
|
|
330
|
+
# and the variable AILK_API_URL (https). Each paid session posts one
|
|
331
|
+
# test-mode lead to the hub. Test subscriptions it creates are cancelled.
|
|
332
|
+
name: Checkout e2e
|
|
333
|
+
|
|
334
|
+
on:
|
|
335
|
+
workflow_dispatch:
|
|
336
|
+
inputs:
|
|
337
|
+
offerings:
|
|
338
|
+
description: Comma-separated fixed offering slugs from apps/web/lib/checkout-config.ts
|
|
339
|
+
required: true
|
|
340
|
+
type: string
|
|
341
|
+
|
|
342
|
+
permissions:
|
|
343
|
+
contents: read
|
|
344
|
+
|
|
345
|
+
concurrency:
|
|
346
|
+
group: checkout-e2e
|
|
347
|
+
cancel-in-progress: false
|
|
348
|
+
|
|
349
|
+
jobs:
|
|
350
|
+
checkout-e2e:
|
|
351
|
+
runs-on: ubuntu-latest
|
|
352
|
+
timeout-minutes: 30
|
|
353
|
+
environment: checkout-e2e
|
|
354
|
+
env:
|
|
355
|
+
NEXT_PUBLIC_BASE_URL: http://localhost:3000
|
|
356
|
+
STRIPE_MODE: test
|
|
357
|
+
steps:
|
|
358
|
+
- name: Refuse a live key or a missing setting
|
|
359
|
+
env:
|
|
360
|
+
STRIPE_SECRET_KEY: \${{ secrets.STRIPE_TEST_SECRET_KEY }}
|
|
361
|
+
AILK_SPOKE_KEY: \${{ secrets.AILK_SPOKE_KEY }}
|
|
362
|
+
AILK_API_URL: \${{ vars.AILK_API_URL }}
|
|
363
|
+
run: |
|
|
364
|
+
case "$STRIPE_SECRET_KEY" in
|
|
365
|
+
sk_test_*|rk_test_*) ;;
|
|
366
|
+
*) echo "::error::STRIPE_TEST_SECRET_KEY must be an sk_test_ or rk_test_ key"; exit 2 ;;
|
|
367
|
+
esac
|
|
368
|
+
if [ -z "$AILK_SPOKE_KEY" ]; then
|
|
369
|
+
echo "::error::set the AILK_SPOKE_KEY secret in the checkout-e2e environment"; exit 2
|
|
370
|
+
fi
|
|
371
|
+
case "$AILK_API_URL" in
|
|
372
|
+
https://*) ;;
|
|
373
|
+
*) echo "::error::AILK_API_URL must be the hub's https origin"; exit 2 ;;
|
|
374
|
+
esac
|
|
375
|
+
|
|
376
|
+
- ${uses("actions/checkout")}
|
|
377
|
+
|
|
378
|
+
- ${uses("pnpm/action-setup")}
|
|
379
|
+
|
|
380
|
+
- run: test -f pnpm-lock.yaml || touch pnpm-lock.yaml
|
|
381
|
+
|
|
382
|
+
- ${uses("actions/setup-node")}
|
|
383
|
+
with:
|
|
384
|
+
node-version: 20
|
|
385
|
+
cache: pnpm
|
|
386
|
+
cache-dependency-path: pnpm-lock.yaml
|
|
387
|
+
|
|
388
|
+
- name: Install
|
|
389
|
+
run: |
|
|
390
|
+
if [ -s pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; else pnpm install; fi
|
|
391
|
+
|
|
392
|
+
- name: Install the Stripe CLI (pinned, checksum-checked)
|
|
393
|
+
run: |
|
|
394
|
+
curl -fsSL -o "$RUNNER_TEMP/stripe.tar.gz" \\
|
|
395
|
+
https://github.com/stripe/stripe-cli/releases/download/v${STRIPE_CLI_VERSION}/stripe_${STRIPE_CLI_VERSION}_linux_x86_64.tar.gz
|
|
396
|
+
echo "${STRIPE_CLI_LINUX_X64_SHA256} $RUNNER_TEMP/stripe.tar.gz" | sha256sum -c -
|
|
397
|
+
mkdir -p "$RUNNER_TEMP/stripe-cli"
|
|
398
|
+
tar -xzf "$RUNNER_TEMP/stripe.tar.gz" -C "$RUNNER_TEMP/stripe-cli" stripe
|
|
399
|
+
|
|
400
|
+
- name: Install Playwright (pinned) and Chromium
|
|
401
|
+
run: |
|
|
402
|
+
npm install --no-save --ignore-scripts --prefix "$RUNNER_TEMP/e2e-deps" playwright@${E2E_PLAYWRIGHT_VERSION}
|
|
403
|
+
"$RUNNER_TEMP/e2e-deps/node_modules/.bin/playwright" install --with-deps chromium
|
|
404
|
+
|
|
405
|
+
- name: Fetch the Stripe CLI's signing secret
|
|
406
|
+
env:
|
|
407
|
+
STRIPE_API_KEY: \${{ secrets.STRIPE_TEST_SECRET_KEY }}
|
|
408
|
+
run: |
|
|
409
|
+
umask 077
|
|
410
|
+
secret="$("$RUNNER_TEMP/stripe-cli/stripe" listen --print-secret)"
|
|
411
|
+
echo "::add-mask::$secret"
|
|
412
|
+
printf '%s' "$secret" > "$RUNNER_TEMP/stripe-whsec"
|
|
413
|
+
|
|
414
|
+
- name: Build the site
|
|
415
|
+
run: pnpm --filter ./apps/web build
|
|
416
|
+
|
|
417
|
+
- name: Start the site
|
|
418
|
+
env:
|
|
419
|
+
STRIPE_SECRET_KEY: \${{ secrets.STRIPE_TEST_SECRET_KEY }}
|
|
420
|
+
AILK_SPOKE_KEY: \${{ secrets.AILK_SPOKE_KEY }}
|
|
421
|
+
AILK_API_URL: \${{ vars.AILK_API_URL }}
|
|
422
|
+
run: |
|
|
423
|
+
STRIPE_WEBHOOK_SECRET="$(cat "$RUNNER_TEMP/stripe-whsec")"
|
|
424
|
+
export STRIPE_WEBHOOK_SECRET
|
|
425
|
+
cd apps/web
|
|
426
|
+
nohup pnpm exec next start -p 3000 -H localhost > "$RUNNER_TEMP/next.log" 2>&1 &
|
|
427
|
+
echo $! > "$RUNNER_TEMP/next.pid"
|
|
428
|
+
for _ in $(seq 1 60); do
|
|
429
|
+
curl -fsS -o /dev/null http://localhost:3000/checkout/thank-you && exit 0
|
|
430
|
+
sleep 2
|
|
431
|
+
done
|
|
432
|
+
echo "::error::the site did not start on localhost:3000; run it locally with the same settings to see why"
|
|
433
|
+
exit 1
|
|
434
|
+
|
|
435
|
+
- name: Pay each offering
|
|
436
|
+
working-directory: apps/web
|
|
437
|
+
env:
|
|
438
|
+
STRIPE_SECRET_KEY: \${{ secrets.STRIPE_TEST_SECRET_KEY }}
|
|
439
|
+
OFFERINGS: \${{ inputs.offerings }}
|
|
440
|
+
SPOKE_CHECKOUT_E2E_DEPS: \${{ runner.temp }}/e2e-deps
|
|
441
|
+
STRIPE_CLI_PATH: \${{ runner.temp }}/stripe-cli/stripe
|
|
442
|
+
run: |
|
|
443
|
+
STRIPE_WEBHOOK_SECRET="$(cat "$RUNNER_TEMP/stripe-whsec")"
|
|
444
|
+
export STRIPE_WEBHOOK_SECRET
|
|
445
|
+
pnpm exec spoke-checkout-e2e --base-url http://localhost:3000 --offerings "$OFFERINGS" --out "$RUNNER_TEMP/checkout-e2e"
|
|
446
|
+
|
|
447
|
+
- name: Stop the site
|
|
448
|
+
if: always()
|
|
449
|
+
run: |
|
|
450
|
+
if [ -f "$RUNNER_TEMP/next.pid" ]; then pkill -P "$(cat "$RUNNER_TEMP/next.pid")" || true; kill "$(cat "$RUNNER_TEMP/next.pid")" || true; fi
|
|
451
|
+
rm -f "$RUNNER_TEMP/stripe-whsec"
|
|
452
|
+
|
|
453
|
+
- name: Keep the report
|
|
454
|
+
if: always()
|
|
455
|
+
${uses("actions/upload-artifact")}
|
|
456
|
+
with:
|
|
457
|
+
name: checkout-e2e
|
|
458
|
+
path: \${{ runner.temp }}/checkout-e2e
|
|
459
|
+
if-no-files-found: ignore
|
|
460
|
+
retention-days: 7
|
|
461
|
+
`;
|
|
289
462
|
/**
|
|
290
463
|
* The slice for `site`, in order: the two route files, the config, the two
|
|
291
|
-
* pages (D3)
|
|
464
|
+
* pages (D3), then the checkout e2e workflow (#7839 D8), which does not
|
|
465
|
+
* depend on `site`. Throws `TopologyError` for an invalid stamp (SEC-7838-10).
|
|
292
466
|
*/
|
|
293
467
|
export function renderSpokeCheckoutFiles(site) {
|
|
294
468
|
assertSite(site);
|
|
@@ -300,6 +474,7 @@ export function renderSpokeCheckoutFiles(site) {
|
|
|
300
474
|
[SPOKE_CHECKOUT_CONFIG_PATH, renderConfig(site)],
|
|
301
475
|
[thankYouPath, THANK_YOU_PAGE],
|
|
302
476
|
[unavailablePath, UNAVAILABLE_PAGE],
|
|
477
|
+
[SPOKE_CHECKOUT_E2E_WORKFLOW_PATH, CHECKOUT_E2E_WORKFLOW],
|
|
303
478
|
];
|
|
304
479
|
}
|
|
305
480
|
const WEB_PACKAGE_JSON = "apps/web/package.json";
|
|
@@ -319,10 +494,32 @@ function withDependency(deps, name, version) {
|
|
|
319
494
|
out[name] = version;
|
|
320
495
|
return out;
|
|
321
496
|
}
|
|
497
|
+
/** A plain `x.y.z`, `^x.y.z` or `~x.y.z` pin's version; null for any other range. */
|
|
498
|
+
function pinnedVersion(range) {
|
|
499
|
+
const match = /^[\^~]?(\d+)\.(\d+)\.(\d+)$/.exec(range.trim());
|
|
500
|
+
return match === null
|
|
501
|
+
? null
|
|
502
|
+
: [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
503
|
+
}
|
|
504
|
+
/** True when `current` pins a version strictly older than `wanted` does. */
|
|
505
|
+
function isOlderPin(current, wanted) {
|
|
506
|
+
const a = pinnedVersion(current);
|
|
507
|
+
const b = pinnedVersion(wanted);
|
|
508
|
+
if (a === null || b === null)
|
|
509
|
+
return false;
|
|
510
|
+
for (let i = 0; i < 3; i++) {
|
|
511
|
+
if (a[i] !== b[i])
|
|
512
|
+
return a[i] < b[i];
|
|
513
|
+
}
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
322
516
|
/**
|
|
323
517
|
* Write the slice into `targetDir` (D3). A file that already exists is left
|
|
324
518
|
* byte-for-byte and reported `skipped` — the config is the site's once
|
|
325
|
-
* written. Adds the package to `apps/web/package.json`
|
|
519
|
+
* written. Adds the package to `apps/web/package.json` when absent, and
|
|
520
|
+
* raises a plain pin older than the one it writes (#7839): the generated
|
|
521
|
+
* checkout e2e workflow runs a command a `^0.2.0` pin never reaches. Any
|
|
522
|
+
* other range — newer, `workspace:`, a tag — is the site's and is kept.
|
|
326
523
|
* Every refusal (an invalid stamp, a version map without the package, a
|
|
327
524
|
* missing or malformed `apps/web/package.json`) throws before the first write.
|
|
328
525
|
*/
|
|
@@ -345,8 +542,14 @@ export function writeSpokeCheckoutSlice(targetDir, opts) {
|
|
|
345
542
|
rows.push({ path, action: "written" });
|
|
346
543
|
}
|
|
347
544
|
const deps = pkg.dependencies ?? {};
|
|
348
|
-
|
|
349
|
-
|
|
545
|
+
const current = Object.prototype.hasOwnProperty.call(deps, SPOKE_CHECKOUT_PACKAGE)
|
|
546
|
+
? deps[SPOKE_CHECKOUT_PACKAGE]
|
|
547
|
+
: undefined;
|
|
548
|
+
if (current === undefined || isOlderPin(current, version)) {
|
|
549
|
+
pkg.dependencies =
|
|
550
|
+
current === undefined
|
|
551
|
+
? withDependency(deps, SPOKE_CHECKOUT_PACKAGE, version)
|
|
552
|
+
: { ...deps, [SPOKE_CHECKOUT_PACKAGE]: version };
|
|
350
553
|
const after = `${JSON.stringify(pkg, null, 2)}${before.endsWith("\n") ? "\n" : ""}`;
|
|
351
554
|
writeFileSync(pkgPath, after);
|
|
352
555
|
rows.push({
|
package/dist/topology.d.ts
CHANGED
|
@@ -122,6 +122,14 @@ export declare function isSpokeExcluded(path: string, opts: IsSpokeExcludedOptio
|
|
|
122
122
|
* install passes no remap.
|
|
123
123
|
*/
|
|
124
124
|
export declare function spokeDestination(destination: string): string;
|
|
125
|
+
/**
|
|
126
|
+
* The MCP tools a spoke does not serve (#8070 D7). `create_project` spawns
|
|
127
|
+
* `packages/create-ailk/dist/cli.js` from the MCP host's working directory,
|
|
128
|
+
* which a spoke does not carry. Both seams carry the list, so the MCP
|
|
129
|
+
* server's registration and the web discovery manifest agree; a hub's is
|
|
130
|
+
* empty.
|
|
131
|
+
*/
|
|
132
|
+
export declare const SPOKE_UNSERVED_MCP_TOOLS: readonly string[];
|
|
125
133
|
/**
|
|
126
134
|
* `apps/web/lib/topology.generated.ts` for `topology` (D5) and, on a spoke,
|
|
127
135
|
* its sign-in mode (#7894 D8). The hub render loads `auth.ts` only when a
|
package/dist/topology.js
CHANGED
|
@@ -269,13 +269,30 @@ const WEB_SEAM_HEADER = `// topology.generated.ts — how this site reads a sess
|
|
|
269
269
|
// every scaffold rewrites it. A hub reads the session through Better Auth.
|
|
270
270
|
// A spoke (\`--topology spoke\`) has no database of its own: by default it
|
|
271
271
|
// has no sign-in, so its render reads no session and imports nothing; with
|
|
272
|
-
// \`--hub-sign-in\` (#7894) it reads the sealed hub-session cookie
|
|
272
|
+
// \`--hub-sign-in\` (#7894) it reads the sealed hub-session cookie.
|
|
273
|
+
// UNSERVED_MCP_TOOLS names the MCP tools /.well-known/mcp.json leaves out
|
|
274
|
+
// on this topology (#8070).`;
|
|
273
275
|
const MCP_SEAM_HEADER = `// topology.generated.ts — how this MCP server resolves its workspace store.
|
|
274
276
|
//
|
|
275
277
|
// GENERATED by create-ailk for this site's topology (#7426). Do not edit:
|
|
276
278
|
// every scaffold rewrites it. A hub resolves its workspace through the
|
|
277
279
|
// database; a spoke (\`--topology spoke\`) has none, so its content tools
|
|
278
|
-
// read the spoke's own MDX and its writes go to the hub at AILK_API_URL
|
|
280
|
+
// read the spoke's own MDX and its writes go to the hub at AILK_API_URL.
|
|
281
|
+
// UNSERVED_MCP_TOOLS names the tools this server does not register on this
|
|
282
|
+
// topology (#8070).`;
|
|
283
|
+
/**
|
|
284
|
+
* The MCP tools a spoke does not serve (#8070 D7). `create_project` spawns
|
|
285
|
+
* `packages/create-ailk/dist/cli.js` from the MCP host's working directory,
|
|
286
|
+
* which a spoke does not carry. Both seams carry the list, so the MCP
|
|
287
|
+
* server's registration and the web discovery manifest agree; a hub's is
|
|
288
|
+
* empty.
|
|
289
|
+
*/
|
|
290
|
+
export const SPOKE_UNSERVED_MCP_TOOLS = ["create_project"];
|
|
291
|
+
/** The `UNSERVED_MCP_TOOLS` export every web and mcp seam render carries. */
|
|
292
|
+
function unservedToolsExport(topology) {
|
|
293
|
+
const list = topology === "spoke" ? SPOKE_UNSERVED_MCP_TOOLS : [];
|
|
294
|
+
return `export const UNSERVED_MCP_TOOLS: readonly string[] = ${JSON.stringify(list)};`;
|
|
295
|
+
}
|
|
279
296
|
const SITE_SESSION_TYPE = `export type SiteSession = {
|
|
280
297
|
user: {
|
|
281
298
|
name?: string | null;
|
|
@@ -304,6 +321,7 @@ export function renderWebSeam(topology, signIn = "off") {
|
|
|
304
321
|
return `${WEB_SEAM_HEADER}
|
|
305
322
|
|
|
306
323
|
export const TOPOLOGY: "hub" | "spoke" = "hub";
|
|
324
|
+
${unservedToolsExport("hub")}
|
|
307
325
|
${signInExports({ enabled: true, provider: "site", signInPath: "/sign-in", signUpPath: "/sign-up", signOutEndpoint: null })}
|
|
308
326
|
|
|
309
327
|
${SITE_SESSION_TYPE}
|
|
@@ -324,6 +342,7 @@ export async function registerSiteStores(): Promise<void> {
|
|
|
324
342
|
import { readHubSession } from "./hub-session/session";
|
|
325
343
|
|
|
326
344
|
export const TOPOLOGY: "hub" | "spoke" = "spoke";
|
|
345
|
+
${unservedToolsExport("spoke")}
|
|
327
346
|
${signInExports({ enabled: true, provider: "hub", signInPath: "/account", signUpPath: null, signOutEndpoint: "/api/hub-session/sign-out" })}
|
|
328
347
|
|
|
329
348
|
${SITE_SESSION_TYPE}
|
|
@@ -340,6 +359,7 @@ export function registerSiteStores(): Promise<void> {
|
|
|
340
359
|
return `${WEB_SEAM_HEADER}
|
|
341
360
|
|
|
342
361
|
export const TOPOLOGY: "hub" | "spoke" = "spoke";
|
|
362
|
+
${unservedToolsExport("spoke")}
|
|
343
363
|
${signInExports({ enabled: false, provider: "none", signInPath: null, signUpPath: null, signOutEndpoint: null })}
|
|
344
364
|
|
|
345
365
|
${SITE_SESSION_TYPE}
|
|
@@ -504,6 +524,7 @@ export { registerLicenseKeyStore } from "./license-key-store.js";
|
|
|
504
524
|
export type { McpWorkspaceResolution };
|
|
505
525
|
|
|
506
526
|
export const TOPOLOGY: "hub" | "spoke" = "hub";
|
|
527
|
+
${unservedToolsExport("hub")}
|
|
507
528
|
|
|
508
529
|
export async function initPublicWorkspace(): Promise<void> {
|
|
509
530
|
await initMcpPublicWorkspace();
|
|
@@ -523,6 +544,7 @@ export type McpWorkspaceResolution =
|
|
|
523
544
|
| { kind: "unresolved"; reason: "ambiguous" };
|
|
524
545
|
|
|
525
546
|
export const TOPOLOGY: "hub" | "spoke" = "spoke";
|
|
547
|
+
${unservedToolsExport("spoke")}
|
|
526
548
|
|
|
527
549
|
export function initPublicWorkspace(): Promise<void> {
|
|
528
550
|
return Promise.resolve();
|
|
@@ -782,8 +804,9 @@ export function renderSpokeHardFloors(opts) {
|
|
|
782
804
|
" # Surfaces that always require human review on merge, regardless of",
|
|
783
805
|
" # automation level. This site is a SPOKE (create-ailk --topology spoke):",
|
|
784
806
|
" # it has no apps/api, no database and no sign-in of its own. Pricing, the",
|
|
785
|
-
" # Stripe webhook route, the checkout route
|
|
786
|
-
" # sign-in paths are floored so their arrival here is a
|
|
807
|
+
" # Stripe webhook route, the checkout route, its price map and its e2e",
|
|
808
|
+
" # workflow, and the sign-in paths are floored so their arrival here is a",
|
|
809
|
+
" # human merge.",
|
|
787
810
|
" - kind: pricing",
|
|
788
811
|
" - kind: stripe_live_mode",
|
|
789
812
|
" - paths:",
|
|
@@ -792,7 +815,8 @@ export function renderSpokeHardFloors(opts) {
|
|
|
792
815
|
" - paths:",
|
|
793
816
|
' - "apps/web/app/api/checkout/**"',
|
|
794
817
|
' - "apps/web/lib/checkout-config.ts"',
|
|
795
|
-
"
|
|
818
|
+
' - ".github/workflows/checkout-e2e.yml"',
|
|
819
|
+
" reason: Spoke checkout — the buy route, the price map and the checkout e2e workflow require human review",
|
|
796
820
|
" - paths:",
|
|
797
821
|
' - "apps/web/auth.ts"',
|
|
798
822
|
' - "apps/web/app/api/auth/**"',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-ailk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
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/
|
|
31
|
-
"@working-theory/
|
|
30
|
+
"@working-theory/templates": "0.38.0",
|
|
31
|
+
"@working-theory/schema": "0.38.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
|
@@ -8,6 +8,50 @@ Inherits root `CLAUDE.md`. This file adds MCP-specific rules.
|
|
|
8
8
|
- Transport: stdio at launch
|
|
9
9
|
- Discovery: `/.well-known/mcp.json` published from `apps/web`
|
|
10
10
|
|
|
11
|
+
## Two entrypoints, one boot (#8070)
|
|
12
|
+
|
|
13
|
+
- **`src/index.ts`** — run locally: stdio (the default), or HTTP on `MCP_HTTP_PORT` with
|
|
14
|
+
`MCP_TRANSPORT=http`.
|
|
15
|
+
- **`src/app.ts`** — the Vercel entry. A Vercel project whose root directory is `apps/mcp`
|
|
16
|
+
detects it (it imports Fastify) and calls its default export, `serverlessHandler(app)`: a
|
|
17
|
+
`(req, res)` function, the shape Vercel's Node runtime runs — a bare Fastify instance is not
|
|
18
|
+
one and answers every request with a 500. It never listens. There is **no `vercel.json`**: a
|
|
19
|
+
`functions` pattern for `src/app.ts` is rejected for the `fastify` builder, so the site's
|
|
20
|
+
`content/` reaches the function by the builder's file tracing, and the 30-second request cap
|
|
21
|
+
is `export const config = { maxDuration: 30 }` in `app.ts` (a literal object — the builder
|
|
22
|
+
reads it by static analysis). `tsconfig.json` names `module` and `moduleResolution` itself:
|
|
23
|
+
Vercel's type-check edits the entry's nearest `tsconfig.json` as raw JSON before `extends`,
|
|
24
|
+
and without its own `module` it switches `strict` off, which fails 11 narrowing sites.
|
|
25
|
+
- Both call `bootMcp()` in `src/boot.ts` before building a transport, and read the HTTP
|
|
26
|
+
configuration through `httpTransportConfigFromEnv()` there. Add a boot step to `bootMcp()`,
|
|
27
|
+
never to one entry. `src/app.ts` stays thin; what a test needs is in `src/serverless-app.ts`.
|
|
28
|
+
- **`MCP_HTTP_STATELESS`** picks the transport mode: `true`/`1` stateless (no sessions; any
|
|
29
|
+
instance answers), `false`/`0` stateful (sessions in one process's memory). Unset, the
|
|
30
|
+
Vercel entry is stateless and `src/index.ts` is stateful. Any other value fails the boot.
|
|
31
|
+
The Vercel entry needs stateless: a session kept in one instance is lost on the next.
|
|
32
|
+
- **Content root.** Every content tool and `audit` take their root from `mcpContentRoot()`
|
|
33
|
+
(`lib/content-adapter.ts`): `AILK_CONTENT_ROOT`, else `<cwd>/content`, else
|
|
34
|
+
`<cwd>/../../content`. Never build a content path from `process.cwd()` in a tool.
|
|
35
|
+
- **`create_project`** is not served on a spoke: the generated seam's `UNSERVED_MCP_TOOLS`
|
|
36
|
+
keeps it out of registration, and the web manifest drops the same list.
|
|
37
|
+
|
|
38
|
+
### What a spoke's MCP host on Vercel enforces
|
|
39
|
+
|
|
40
|
+
- **The address is the caller's, behind exactly one proxy.** The Vercel entry trusts one hop
|
|
41
|
+
(`trustOneHop`, `src/serverless-app.ts`), so the limits key on the address Vercel's edge
|
|
42
|
+
saw and a caller's own `X-Forwarded-For` cannot move the key. This holds when the MCP host
|
|
43
|
+
name points straight at the Vercel project; a second proxy in front keys every caller on
|
|
44
|
+
that proxy. It is a function, not `trustProxy: 1`, because Fastify 5.12 treats a numeric
|
|
45
|
+
hop count as trusting nothing. The long-running entry trusts no proxy.
|
|
46
|
+
- **Per address, per instance.** 60 requests a minute across reads and writes (the write rate
|
|
47
|
+
is parsed but not enforced); `capture_lead` 50 a day and `start_purchase` 20 a day.
|
|
48
|
+
`subscribe_newsletter` and `request_callback` have no daily cap. Each warm instance counts
|
|
49
|
+
on its own and a cold start resets the counts, so a caller reaching N instances gets up to
|
|
50
|
+
N times each limit.
|
|
51
|
+
- **At the hub, one shared bucket.** The MCP lead tools forward no visitor address, so the
|
|
52
|
+
hub's lead limiter counts every MCP lead write from a spoke against the MCP host's egress
|
|
53
|
+
address (#8073).
|
|
54
|
+
|
|
11
55
|
## Identity-blind by design (hard rule)
|
|
12
56
|
|
|
13
57
|
This rule governs the anonymous surface, `/mcp` (and stdio) — every tool the catalog returns with
|
|
@@ -56,14 +100,14 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
|
|
|
56
100
|
server-wide gate and its own Gate 2 in `tools/purchase.ts`.
|
|
57
101
|
`MCP_FEATURE_GATE_MODE` (`packages/metadata`) decides what an unheld
|
|
58
102
|
feature does. In `"warn"` mode (this release) the five register on module
|
|
59
|
-
presence alone, and `
|
|
103
|
+
presence alone, and the boot (`bootMcp()`) logs one warning (stderr) per tool
|
|
60
104
|
served without its feature. In `"refuse"` mode (the next release) they
|
|
61
105
|
also need the deployment's licence to hold the feature, and
|
|
62
106
|
`/.well-known/mcp.json` drops them in step. The claim is resolved at boot
|
|
63
107
|
and re-read every `MCP_CLAIM_REFRESH_MS` through `checkLicense()`
|
|
64
108
|
(`lib/commerce-claim.ts`) and fails closed.
|
|
65
|
-
`index.ts`
|
|
66
|
-
is entirely via `createMcpServer`.
|
|
109
|
+
`index.ts` and `app.ts` are the transport entry points and export no tools —
|
|
110
|
+
registration is entirely via `createMcpServer`.
|
|
67
111
|
|
|
68
112
|
## The signed-in workspace surface (`/mcp/workspace`, #3591)
|
|
69
113
|
|
|
@@ -118,7 +162,7 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
|
|
|
118
162
|
server's tool list, and calling it by name there returns the module-disabled
|
|
119
163
|
`not_found` body. The claim is resolved at boot and re-read every
|
|
120
164
|
`MCP_CLAIM_REFRESH_MS`
|
|
121
|
-
(`src/lib/commerce-claim.ts`, awaited in `src/
|
|
165
|
+
(`src/lib/commerce-claim.ts`, awaited in `bootMcp()`, `src/boot.ts`) and read
|
|
122
166
|
synchronously by both the registration filter and the handler's own
|
|
123
167
|
re-check. Calls are rate-limited per IP by the AbuseGuard
|
|
124
168
|
(`MCP_ABUSE_START_PURCHASE_DAILY_CAP`, default 20, on its own tracker).
|
|
@@ -164,7 +208,9 @@ Example (lead routing):
|
|
|
164
208
|
- Don't add a tool without the matching `apps/api` endpoint.
|
|
165
209
|
- Don't put business logic in tool handlers — they're adapters.
|
|
166
210
|
|
|
167
|
-
Last updated: 2026-10-
|
|
211
|
+
Last updated: 2026-10-10 — #8070 (amendment 2): the Vercel entry exports a handler, no `vercel.json`; § Two entrypoints, one boot — the Vercel entry `src/app.ts`, the shared `src/boot.ts`, `MCP_HTTP_STATELESS`, `mcpContentRoot()`, `create_project` withheld on a spoke, and what a spoke's MCP host enforces — verified against `apps/mcp/src/{app,boot,serverless-app,index,http-transport,server}.ts` and `lib/content-adapter.ts`.
|
|
212
|
+
|
|
213
|
+
Prior: 2026-10-04 — #3591 PR-E (#7684): the identity-blind rule is scoped to the anonymous `/mcp`, and the new § The signed-in workspace surface records `/mcp/workspace` — POST-only and stateless, an agent key per request forwarded to each tool's apps/api twin, eleven `surface: "workspace"` catalog rows that are parity `twin`s and never in the discovery manifest — verified against `apps/mcp/src/http-transport.ts`, `apps/mcp/src/workspace-server.ts` and `packages/metadata/src/mcp-tool-catalog.ts`.
|
|
168
214
|
|
|
169
215
|
Prior: 2026-09-29 — #7136: the five `tier: "pro"` tools name their licence `feature:` (`scheduling`, `flows-deliverables`) and gate on the deployment's own claim under `MCP_FEATURE_GATE_MODE` — `"warn"` this release (served, one boot warning each), `"refuse"` the next. The unused call-time paid-tier guard is deleted.
|
|
170
216
|
|