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.
- package/dist/spoke-checkout.d.ts +27 -2
- package/dist/spoke-checkout.js +207 -4
- package/dist/topology.js +5 -3
- package/package.json +3 -3
- 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/versions.json +1 -1
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.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
|
|
786
|
-
" # sign-in paths are floored so their arrival here is a
|
|
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
|
-
"
|
|
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.
|
|
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/
|
|
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",
|
|
@@ -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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
49
|
-
mockCookies.mockResolvedValue({
|
|
50
|
-
toString: () => "better-auth.session_token=abc",
|
|
51
|
-
});
|
|
52
|
-
mockHeaders.mockResolvedValue(new Headers());
|
|
42
|
+
const init = await forwardedCookieInit();
|
|
53
43
|
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
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",
|