create-ailk 0.16.0 → 0.17.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.
@@ -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). Throws `TopologyError` for an invalid stamp (SEC-7838-10).
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` only when absent.
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
  */
@@ -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). Throws `TopologyError` for an invalid stamp (SEC-7838-10).
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` only when absent.
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
- if (!Object.prototype.hasOwnProperty.call(deps, SPOKE_CHECKOUT_PACKAGE)) {
349
- pkg.dependencies = withDependency(deps, SPOKE_CHECKOUT_PACKAGE, version);
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.js CHANGED
@@ -782,8 +782,9 @@ export function renderSpokeHardFloors(opts) {
782
782
  " # Surfaces that always require human review on merge, regardless of",
783
783
  " # automation level. This site is a SPOKE (create-ailk --topology spoke):",
784
784
  " # it has no apps/api, no database and no sign-in of its own. Pricing, the",
785
- " # Stripe webhook route, the checkout route and its price map, and the",
786
- " # sign-in paths are floored so their arrival here is a human merge.",
785
+ " # Stripe webhook route, the checkout route, its price map and its e2e",
786
+ " # workflow, and the sign-in paths are floored so their arrival here is a",
787
+ " # human merge.",
787
788
  " - kind: pricing",
788
789
  " - kind: stripe_live_mode",
789
790
  " - paths:",
@@ -792,7 +793,8 @@ export function renderSpokeHardFloors(opts) {
792
793
  " - paths:",
793
794
  ' - "apps/web/app/api/checkout/**"',
794
795
  ' - "apps/web/lib/checkout-config.ts"',
795
- " reason: Spoke checkout — the buy route and the price map require human review",
796
+ ' - ".github/workflows/checkout-e2e.yml"',
797
+ " reason: Spoke checkout — the buy route, the price map and the checkout e2e workflow require human review",
796
798
  " - paths:",
797
799
  ' - "apps/web/auth.ts"',
798
800
  ' - "apps/web/app/api/auth/**"',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ailk",
3
- "version": "0.16.0",
3
+ "version": "0.17.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/schema": "0.38.0",
31
- "@working-theory/templates": "0.38.0"
30
+ "@working-theory/templates": "0.38.0",
31
+ "@working-theory/schema": "0.38.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -15,6 +15,13 @@
15
15
 
16
16
  import { GET } from "../route";
17
17
 
18
+ // The REAL committed seam, for the #8060 hub-only guards: the mock below
19
+ // shadows `TOPOLOGY` per case, but whether a hub-only case runs depends on the
20
+ // render this scaffold carries, not on the topology a case picks.
21
+ const { TOPOLOGY: SEAM_TOPOLOGY } = jest.requireActual<
22
+ typeof import("../../../lib/topology.generated")
23
+ >("../../../lib/topology.generated");
24
+
18
25
  // The generated topology seam (#7426 D5), mocked so each case picks its
19
26
  // topology: the hub cases below run against `"hub"`, exactly as before; the
20
27
  // spoke cases (D12) flip it. Mocked rather than imported because the hub
@@ -93,17 +100,21 @@ describe("GET /openapi.json", () => {
93
100
  expect(requestedUrl()).toBe("http://api.internal:4000/v1/openapi.json");
94
101
  });
95
102
 
96
- it("falls back to NEXT_PUBLIC_AILK_API_URL, then the local-dev API origin", async () => {
97
- apiAnswers(200, JSON.stringify(SPEC));
98
- await GET();
99
- expect(requestedUrl()).toBe("http://localhost:3001/v1/openapi.json");
100
-
101
- fetchSpy.mockClear();
102
- process.env["NEXT_PUBLIC_AILK_API_URL"] = "https://api.example.com/";
103
- apiAnswers(200, JSON.stringify(SPEC));
104
- await GET();
105
- expect(requestedUrl()).toBe("https://api.example.com/v1/openapi.json");
106
- });
103
+ // #8060 — hub-only: a spoke render of the seam does not run this case.
104
+ (SEAM_TOPOLOGY === "hub" ? it : it.skip)(
105
+ "falls back to NEXT_PUBLIC_AILK_API_URL, then the local-dev API origin",
106
+ async () => {
107
+ apiAnswers(200, JSON.stringify(SPEC));
108
+ await GET();
109
+ expect(requestedUrl()).toBe("http://localhost:3001/v1/openapi.json");
110
+
111
+ fetchSpy.mockClear();
112
+ process.env["NEXT_PUBLIC_AILK_API_URL"] = "https://api.example.com/";
113
+ apiAnswers(200, JSON.stringify(SPEC));
114
+ await GET();
115
+ expect(requestedUrl()).toBe("https://api.example.com/v1/openapi.json");
116
+ },
117
+ );
107
118
 
108
119
  // #7874 D12 — the hub case above runs the committed (hub) render of the
109
120
  // browser API origin seam unmocked; the spoke render reads no env at all.
@@ -141,23 +152,30 @@ describe("GET /openapi.json", () => {
141
152
  expect(response.headers.get("cache-control")).toBe("no-store");
142
153
  });
143
154
 
144
- it("resolves a relative servers[] URL against the public API origin", async () => {
145
- process.env["AILK_API_URL"] = "http://api.internal:4000";
146
- process.env["NEXT_PUBLIC_AILK_API_URL"] = "https://api.example.com/";
147
- apiAnswers(
148
- 200,
149
- JSON.stringify({ ...SPEC, servers: [{ url: "/", description: "x" }] }),
150
- );
151
-
152
- const body = (await (await GET()).json()) as typeof SPEC & {
153
- servers: Array<{ url: string }>;
154
- };
155
-
156
- expect(requestedUrl()).toBe("http://api.internal:4000/v1/openapi.json");
157
- expect(body.servers).toEqual([
158
- { url: "https://api.example.com", description: "This deployment's API" },
159
- ]);
160
- });
155
+ // #8060 — hub-only: a spoke render of the seam does not run this case.
156
+ (SEAM_TOPOLOGY === "hub" ? it : it.skip)(
157
+ "resolves a relative servers[] URL against the public API origin",
158
+ async () => {
159
+ process.env["AILK_API_URL"] = "http://api.internal:4000";
160
+ process.env["NEXT_PUBLIC_AILK_API_URL"] = "https://api.example.com/";
161
+ apiAnswers(
162
+ 200,
163
+ JSON.stringify({ ...SPEC, servers: [{ url: "/", description: "x" }] }),
164
+ );
165
+
166
+ const body = (await (await GET()).json()) as typeof SPEC & {
167
+ servers: Array<{ url: string }>;
168
+ };
169
+
170
+ expect(requestedUrl()).toBe("http://api.internal:4000/v1/openapi.json");
171
+ expect(body.servers).toEqual([
172
+ {
173
+ url: "https://api.example.com",
174
+ description: "This deployment's API",
175
+ },
176
+ ]);
177
+ },
178
+ );
161
179
 
162
180
  it("resolves a relative servers[] URL against AILK_API_URL when no public origin is set", async () => {
163
181
  process.env["AILK_API_URL"] = "http://localhost:3700";
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { handleRelay, RELAY_ROUTES } from "../relay";
14
14
  import { __resetRelayRateLimit, __relayRateLimitBucketCount } from "../relay";
15
+ import { TOPOLOGY } from "../topology.generated";
15
16
 
16
17
  const HUB = "https://hub.example";
17
18
  const SITE = "https://spoke.example";
@@ -516,13 +517,18 @@ describe("hardening — what the site serves, and a missing hub address", () =>
516
517
 
517
518
  // #7874 D12 — the fallback reads the browser API origin seam. The
518
519
  // committed seam is the hub render, so this case runs it unmocked.
519
- it("on a hub, NEXT_PUBLIC_AILK_API_URL alone still counts as a hub address", async () => {
520
- delete process.env.AILK_API_URL;
521
- process.env.NEXT_PUBLIC_AILK_API_URL = HUB;
522
- const res = await handleRelay(relayRequest("/v1/leads/submit"));
523
- expect(res.status).toBe(201);
524
- expect(fetchMock).toHaveBeenCalledTimes(1);
525
- });
520
+ // #8060 — hub-only: a spoke render of the seam reads no
521
+ // NEXT_PUBLIC_AILK_API_URL, so a spoke scaffold skips this case.
522
+ (TOPOLOGY === "hub" ? it : it.skip)(
523
+ "on a hub, NEXT_PUBLIC_AILK_API_URL alone still counts as a hub address",
524
+ async () => {
525
+ delete process.env.AILK_API_URL;
526
+ process.env.NEXT_PUBLIC_AILK_API_URL = HUB;
527
+ const res = await handleRelay(relayRequest("/v1/leads/submit"));
528
+ expect(res.status).toBe(201);
529
+ expect(fetchMock).toHaveBeenCalledTimes(1);
530
+ },
531
+ );
526
532
 
527
533
  it("on a spoke (the seam's spoke render), that fallback is skipped", async () => {
528
534
  delete process.env.AILK_API_URL;
@@ -11,6 +11,7 @@
11
11
  * sets `Origin`.
12
12
  */
13
13
  import { forwardedCookieInit } from "../server-api";
14
+ import { TOPOLOGY } from "../topology.generated";
14
15
 
15
16
  const mockCookies = jest.fn();
16
17
  const mockHeaders = jest.fn();
@@ -27,35 +28,43 @@ describe("forwardedCookieInit", () => {
27
28
  mockHeaders.mockReset();
28
29
  });
29
30
 
30
- it("forwards cookie + origin when the incoming request carries an Origin header", async () => {
31
- mockCookies.mockResolvedValue({
32
- toString: () => "better-auth.session_token=abc",
33
- });
34
- mockHeaders.mockResolvedValue(
35
- new Headers({ origin: "https://app.example.com" }),
36
- );
37
-
38
- const init = await forwardedCookieInit();
39
-
40
- expect(init).toEqual({
41
- headers: {
42
- cookie: "better-auth.session_token=abc",
43
- origin: "https://app.example.com",
44
- },
45
- });
46
- });
31
+ // #8060 — hub-only: a spoke render of the seam does not run this case.
32
+ (TOPOLOGY === "hub" ? it : it.skip)(
33
+ "forwards cookie + origin when the incoming request carries an Origin header",
34
+ async () => {
35
+ mockCookies.mockResolvedValue({
36
+ toString: () => "better-auth.session_token=abc",
37
+ });
38
+ mockHeaders.mockResolvedValue(
39
+ new Headers({ origin: "https://app.example.com" }),
40
+ );
47
41
 
48
- it("forwards cookie alone — no origin key at all — when Origin is absent", async () => {
49
- mockCookies.mockResolvedValue({
50
- toString: () => "better-auth.session_token=abc",
51
- });
52
- mockHeaders.mockResolvedValue(new Headers());
42
+ const init = await forwardedCookieInit();
53
43
 
54
- const init = await forwardedCookieInit();
44
+ expect(init).toEqual({
45
+ headers: {
46
+ cookie: "better-auth.session_token=abc",
47
+ origin: "https://app.example.com",
48
+ },
49
+ });
50
+ },
51
+ );
55
52
 
56
- expect(init).toEqual({
57
- headers: { cookie: "better-auth.session_token=abc" },
58
- });
59
- expect(init.headers).not.toHaveProperty("origin");
60
- });
53
+ // #8060 — hub-only: a spoke render of the seam does not run this case.
54
+ (TOPOLOGY === "hub" ? it : it.skip)(
55
+ "forwards cookie alone — no origin key at all — when Origin is absent",
56
+ async () => {
57
+ mockCookies.mockResolvedValue({
58
+ toString: () => "better-auth.session_token=abc",
59
+ });
60
+ mockHeaders.mockResolvedValue(new Headers());
61
+
62
+ const init = await forwardedCookieInit();
63
+
64
+ expect(init).toEqual({
65
+ headers: { cookie: "better-auth.session_token=abc" },
66
+ });
67
+ expect(init.headers).not.toHaveProperty("origin");
68
+ },
69
+ );
61
70
  });
package/versions.json CHANGED
@@ -29,7 +29,7 @@
29
29
  "@working-theory/schema-validator": "0.38.0",
30
30
  "@working-theory/sections-registry": "0.38.0",
31
31
  "@working-theory/site-config": "0.38.0",
32
- "@working-theory/spoke-checkout": "0.3.0",
32
+ "@working-theory/spoke-checkout": "0.4.0",
33
33
  "@working-theory/stripe-client": "0.38.0",
34
34
  "@working-theory/templates": "0.38.0",
35
35
  "@working-theory/theme": "0.38.0",