create-ailk 0.14.0 → 0.16.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 +124 -0
- package/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +80 -3
- package/dist/add-route.js +26 -2
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +103 -3
- package/dist/derive-architecture.d.ts +37 -2
- package/dist/derive-architecture.js +92 -7
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +20 -0
- package/dist/lib/extract-module-bundle.js +53 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +82 -0
- package/dist/parse-args.d.ts +27 -0
- package/dist/parse-args.js +89 -0
- package/dist/programmatic.d.ts +28 -0
- package/dist/programmatic.js +181 -11
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +55 -5
- package/dist/site-manifest.js +121 -12
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +42 -0
- package/dist/spoke-checkout.js +359 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +29 -5
- package/dist/topology.d.ts +246 -0
- package/dist/topology.js +942 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +21 -4
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +41 -3
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +129 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +278 -30
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/CLAUDE.md +1 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +83 -18
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
- package/templates/apps/web/app/openapi.json/route.ts +72 -7
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +209 -3
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/agents.db.test.ts +85 -0
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +24 -0
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +111 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -34
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start a sign-in through the hub (#7894 D2, D7) — `GET /api/hub-session/sign-in?next=<path>`.
|
|
3
|
+
*
|
|
4
|
+
* Mints a `state` and a PKCE verifier (32 random bytes each, base64url), seals
|
|
5
|
+
* them with the sanitized `next` into the flow cookie for ten minutes, and
|
|
6
|
+
* sends the visitor to the hub's `/api/auth/hub/authorize` with the S256
|
|
7
|
+
* challenge. The redirect URI is this site's configured origin plus
|
|
8
|
+
* `/api/hub-session/callback` — never derived from the request's `Host`.
|
|
9
|
+
*
|
|
10
|
+
* - not a hub-mode spoke (`SIGN_IN_PROVIDER` is not `"hub"`): 404;
|
|
11
|
+
* - hub mode but not configured: 303 to `/account`, whose gate says so.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here logs the state, the verifier or the challenge.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
16
|
+
|
|
17
|
+
import { hubSessionConfig } from "../../../../lib/hub-session/config";
|
|
18
|
+
import { seal } from "../../../../lib/hub-session/seal";
|
|
19
|
+
import {
|
|
20
|
+
HUB_SESSION_FALLBACK_PATH,
|
|
21
|
+
cookieHeader,
|
|
22
|
+
flowCookieName,
|
|
23
|
+
hubNextPath,
|
|
24
|
+
} from "../../../../lib/hub-session/session";
|
|
25
|
+
import { SIGN_IN_PROVIDER } from "../../../../lib/topology.generated";
|
|
26
|
+
|
|
27
|
+
/** How long a sign-in may take on the hub before the flow cookie expires. */
|
|
28
|
+
const FLOW_TTL_SECONDS = 600;
|
|
29
|
+
|
|
30
|
+
/** The hub's browser step (spec 7894 D4). */
|
|
31
|
+
const AUTHORIZE_PATH = "/api/auth/hub/authorize";
|
|
32
|
+
|
|
33
|
+
function see(location: string, setCookie?: string): Response {
|
|
34
|
+
const headers = new Headers({
|
|
35
|
+
Location: location,
|
|
36
|
+
"Cache-Control": "no-store",
|
|
37
|
+
"Referrer-Policy": "no-referrer",
|
|
38
|
+
});
|
|
39
|
+
if (setCookie !== undefined) headers.append("Set-Cookie", setCookie);
|
|
40
|
+
return new Response(null, { status: 303, headers });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
|
|
44
|
+
export function GET(request: Request): Response {
|
|
45
|
+
if (SIGN_IN_PROVIDER !== "hub") return new Response(null, { status: 404 });
|
|
46
|
+
const config = hubSessionConfig();
|
|
47
|
+
if (config === null) return see(HUB_SESSION_FALLBACK_PATH);
|
|
48
|
+
|
|
49
|
+
const next = hubNextPath(new URL(request.url).searchParams.get("next"));
|
|
50
|
+
const state = randomBytes(32).toString("base64url");
|
|
51
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
52
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
53
|
+
|
|
54
|
+
const name = flowCookieName(config);
|
|
55
|
+
const flow = seal(
|
|
56
|
+
{ v: 1, state, verifier, next, expiresAt: Date.now() + FLOW_TTL_SECONDS * 1000 },
|
|
57
|
+
config.sessionSecret,
|
|
58
|
+
"flow",
|
|
59
|
+
name,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const authorize = new URL(AUTHORIZE_PATH, config.hubOrigin);
|
|
63
|
+
authorize.search = new URLSearchParams({
|
|
64
|
+
response_type: "code",
|
|
65
|
+
client_id: config.clientId,
|
|
66
|
+
redirect_uri: config.redirectUri,
|
|
67
|
+
state,
|
|
68
|
+
code_challenge: challenge,
|
|
69
|
+
code_challenge_method: "S256",
|
|
70
|
+
}).toString();
|
|
71
|
+
|
|
72
|
+
return see(
|
|
73
|
+
authorize.toString(),
|
|
74
|
+
cookieHeader(name, flow, FLOW_TTL_SECONDS, config),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign out of a spoke that signs in through its hub (#7894 D7) — `POST /api/hub-session/sign-out`.
|
|
3
|
+
*
|
|
4
|
+
* The signed-in Nav's "Sign out" posts here (`SIGN_OUT_ENDPOINT`). It revokes
|
|
5
|
+
* the credential at the hub (best-effort: a hub that cannot be reached still
|
|
6
|
+
* leaves the visitor signed out here), clears the session cookie and answers
|
|
7
|
+
* 204.
|
|
8
|
+
*
|
|
9
|
+
* Same-origin only: a `Sec-Fetch-Site` other than `same-origin`, an `Origin`
|
|
10
|
+
* that is not this site's configured origin, or a request carrying neither is
|
|
11
|
+
* refused 403 before anything is revoked, so another site cannot sign a
|
|
12
|
+
* visitor out. POST only — Next answers 405 to any other method.
|
|
13
|
+
*/
|
|
14
|
+
import { hubSignIn } from "@working-theory/api-client";
|
|
15
|
+
|
|
16
|
+
import { hubSessionConfig } from "../../../../lib/hub-session/config";
|
|
17
|
+
import {
|
|
18
|
+
cookieHeader,
|
|
19
|
+
readHubSessionPayload,
|
|
20
|
+
sessionCookieName,
|
|
21
|
+
} from "../../../../lib/hub-session/session";
|
|
22
|
+
import { SIGN_IN_PROVIDER } from "../../../../lib/topology.generated";
|
|
23
|
+
|
|
24
|
+
/** Whether `request` comes from a page on `siteOrigin`. */
|
|
25
|
+
function isSameOrigin(request: Request, siteOrigin: string | null): boolean {
|
|
26
|
+
const fetchSite = request.headers.get("sec-fetch-site");
|
|
27
|
+
const origin = request.headers.get("origin");
|
|
28
|
+
if (fetchSite === null && origin === null) return false;
|
|
29
|
+
if (fetchSite !== null && fetchSite !== "same-origin") return false;
|
|
30
|
+
if (origin !== null && origin !== siteOrigin) return false;
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
|
|
35
|
+
export async function POST(request: Request): Promise<Response> {
|
|
36
|
+
if (SIGN_IN_PROVIDER !== "hub") return new Response(null, { status: 404 });
|
|
37
|
+
const config = hubSessionConfig();
|
|
38
|
+
if (!isSameOrigin(request, config?.siteOrigin ?? null)) {
|
|
39
|
+
return new Response(null, { status: 403 });
|
|
40
|
+
}
|
|
41
|
+
if (config === null) return new Response(null, { status: 204 });
|
|
42
|
+
|
|
43
|
+
const payload = readHubSessionPayload(request.headers);
|
|
44
|
+
if (payload !== null) {
|
|
45
|
+
try {
|
|
46
|
+
await hubSignIn.revoke(
|
|
47
|
+
{ clientId: config.clientId, clientSecret: config.clientSecret },
|
|
48
|
+
{ accessToken: payload.accessToken },
|
|
49
|
+
);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(
|
|
52
|
+
`[hub-session] revoke failed errName=${err instanceof Error ? err.name : "unknown"}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return new Response(null, {
|
|
57
|
+
status: 204,
|
|
58
|
+
headers: {
|
|
59
|
+
"Cache-Control": "no-store",
|
|
60
|
+
"Set-Cookie": cookieHeader(sessionCookieName(config), "", 0, config),
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -15,13 +15,29 @@
|
|
|
15
15
|
|
|
16
16
|
import { GET } from "../route";
|
|
17
17
|
|
|
18
|
+
// The generated topology seam (#7426 D5), mocked so each case picks its
|
|
19
|
+
// topology: the hub cases below run against `"hub"`, exactly as before; the
|
|
20
|
+
// spoke cases (D12) flip it. Mocked rather than imported because the hub
|
|
21
|
+
// render imports `../auth`, which this route never touches.
|
|
22
|
+
const mockTopology: { value: "hub" | "spoke" } = { value: "hub" };
|
|
23
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426 D5), not a runtime input; the seam IS the boundary between a hub and a spoke build, and there is no env or HTTP surface that selects it.
|
|
24
|
+
jest.mock("../../../lib/topology.generated", () => ({
|
|
25
|
+
get TOPOLOGY() {
|
|
26
|
+
return mockTopology.value;
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
18
30
|
const SPEC = {
|
|
19
31
|
openapi: "3.1.0",
|
|
20
32
|
info: { title: "Example API", version: "1.0.0" },
|
|
21
33
|
paths: { "/v1/content/search": {} },
|
|
22
34
|
};
|
|
23
35
|
|
|
24
|
-
const API_ENV = [
|
|
36
|
+
const API_ENV = [
|
|
37
|
+
"AILK_API_URL",
|
|
38
|
+
"NEXT_PUBLIC_AILK_API_URL",
|
|
39
|
+
"NEXT_PUBLIC_BASE_URL",
|
|
40
|
+
] as const;
|
|
25
41
|
const saved: Record<string, string | undefined> = {};
|
|
26
42
|
let fetchSpy: jest.SpyInstance;
|
|
27
43
|
|
|
@@ -34,6 +50,7 @@ beforeEach(() => {
|
|
|
34
50
|
});
|
|
35
51
|
|
|
36
52
|
afterEach(() => {
|
|
53
|
+
mockTopology.value = "hub";
|
|
37
54
|
for (const name of API_ENV) {
|
|
38
55
|
if (saved[name] === undefined) delete process.env[name];
|
|
39
56
|
else process.env[name] = saved[name];
|
|
@@ -88,6 +105,21 @@ describe("GET /openapi.json", () => {
|
|
|
88
105
|
expect(requestedUrl()).toBe("https://api.example.com/v1/openapi.json");
|
|
89
106
|
});
|
|
90
107
|
|
|
108
|
+
// #7874 D12 — the hub case above runs the committed (hub) render of the
|
|
109
|
+
// browser API origin seam unmocked; the spoke render reads no env at all.
|
|
110
|
+
it("skips the NEXT_PUBLIC_AILK_API_URL fallback under the seam's spoke render", async () => {
|
|
111
|
+
process.env["NEXT_PUBLIC_AILK_API_URL"] = "https://api.example.com/";
|
|
112
|
+
apiAnswers(200, JSON.stringify(SPEC));
|
|
113
|
+
await jest.isolateModulesAsync(async () => {
|
|
114
|
+
jest.doMock("../../../lib/api-origin.generated", () => ({
|
|
115
|
+
browserApiOrigin: (): string | undefined => undefined,
|
|
116
|
+
}));
|
|
117
|
+
const spoke = await import("../route");
|
|
118
|
+
await spoke.GET();
|
|
119
|
+
});
|
|
120
|
+
expect(requestedUrl()).toBe("http://localhost:3001/v1/openapi.json");
|
|
121
|
+
});
|
|
122
|
+
|
|
91
123
|
it("answers 502, not 404, when the API cannot be reached", async () => {
|
|
92
124
|
fetchSpy.mockRejectedValue(new TypeError("fetch failed"));
|
|
93
125
|
|
|
@@ -174,3 +206,74 @@ describe("GET /openapi.json", () => {
|
|
|
174
206
|
expect(await response.text()).not.toContain("api.internal");
|
|
175
207
|
});
|
|
176
208
|
});
|
|
209
|
+
|
|
210
|
+
describe("GET /openapi.json on a spoke (#7426 D12)", () => {
|
|
211
|
+
const HUB_SPEC = {
|
|
212
|
+
openapi: "3.1.0",
|
|
213
|
+
info: { title: "Hub API", version: "1.0.0" },
|
|
214
|
+
servers: [{ url: "https://hub.example.com", description: "The hub" }],
|
|
215
|
+
paths: {
|
|
216
|
+
"/v1/leads/submit": {
|
|
217
|
+
post: { summary: "Submit a lead" },
|
|
218
|
+
get: { summary: "Not relayed" },
|
|
219
|
+
},
|
|
220
|
+
"/v1/schedule/availability": {
|
|
221
|
+
get: {
|
|
222
|
+
summary: "Availability",
|
|
223
|
+
servers: [{ url: "https://hub.example.com/schedule" }],
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
"/v1/content/search": { get: { summary: "Hub-only search" } },
|
|
227
|
+
"/v1/admin/sites": { post: { summary: "Hub-only admin" } },
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
beforeEach(() => {
|
|
232
|
+
mockTopology.value = "spoke";
|
|
233
|
+
process.env["AILK_API_URL"] = "https://hub.example.com";
|
|
234
|
+
process.env["NEXT_PUBLIC_BASE_URL"] = "https://spoke.example.com";
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("names only this spoke's own origin in servers[]", async () => {
|
|
238
|
+
apiAnswers(200, JSON.stringify(HUB_SPEC));
|
|
239
|
+
|
|
240
|
+
const response = await GET();
|
|
241
|
+
const body = (await response.json()) as { servers: unknown };
|
|
242
|
+
|
|
243
|
+
expect(response.status).toBe(200);
|
|
244
|
+
expect(body.servers).toEqual([{ url: "https://spoke.example.com" }]);
|
|
245
|
+
expect(JSON.stringify(body)).not.toContain("hub.example.com");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("keeps only the paths and methods the relay forwards", async () => {
|
|
249
|
+
apiAnswers(200, JSON.stringify(HUB_SPEC));
|
|
250
|
+
|
|
251
|
+
const body = (await (await GET()).json()) as {
|
|
252
|
+
paths: Record<string, Record<string, unknown>>;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
expect(body.paths).toEqual({
|
|
256
|
+
"/v1/leads/submit": { post: { summary: "Submit a lead" } },
|
|
257
|
+
"/v1/schedule/availability": { get: { summary: "Availability" } },
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("still reads the hub's document at AILK_API_URL", async () => {
|
|
262
|
+
apiAnswers(200, JSON.stringify(HUB_SPEC));
|
|
263
|
+
|
|
264
|
+
await GET();
|
|
265
|
+
|
|
266
|
+
expect(requestedUrl()).toBe("https://hub.example.com/v1/openapi.json");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("on a hub, leaves servers[] and paths as the API declared them", async () => {
|
|
270
|
+
mockTopology.value = "hub";
|
|
271
|
+
apiAnswers(200, JSON.stringify(HUB_SPEC));
|
|
272
|
+
|
|
273
|
+
const body = (await (await GET()).json()) as typeof HUB_SPEC;
|
|
274
|
+
|
|
275
|
+
expect(body.servers).toEqual(HUB_SPEC.servers);
|
|
276
|
+
expect(body.paths).toEqual(HUB_SPEC.paths);
|
|
277
|
+
expect(JSON.stringify(body)).toContain("hub.example.com");
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -15,8 +15,19 @@
|
|
|
15
15
|
* When the API cannot be reached, or answers anything but an OpenAPI
|
|
16
16
|
* document with a 200, this route answers 502 with a JSON error that names no
|
|
17
17
|
* upstream origin, and is never cached.
|
|
18
|
+
*
|
|
19
|
+
* On a spoke (#7426 D12) the API is the hub's, so the document is rewritten
|
|
20
|
+
* to describe THIS host only: `servers[]` becomes this site's own origin, and
|
|
21
|
+
* `paths` keeps only the (path, method) pairs the same-origin relay forwards
|
|
22
|
+
* (`RELAY_ROUTES`, lib/relay.ts) — an agent reading it never learns the hub's
|
|
23
|
+
* host, and never calls a path this site would answer 404.
|
|
18
24
|
*/
|
|
19
25
|
|
|
26
|
+
import { browserApiOrigin } from "../../lib/api-origin.generated";
|
|
27
|
+
import { RELAY_ROUTES } from "../../lib/relay";
|
|
28
|
+
import { TOPOLOGY } from "../../lib/topology.generated";
|
|
29
|
+
import { resolveBaseUrl } from "../../src/lib/routes";
|
|
30
|
+
|
|
20
31
|
// Read per request: the document is the running API's, and `next build` has
|
|
21
32
|
// no API to read it from.
|
|
22
33
|
export const dynamic = "force-dynamic";
|
|
@@ -33,9 +44,7 @@ const LOCAL_API_ORIGIN = "http://localhost:3001";
|
|
|
33
44
|
*/
|
|
34
45
|
function apiBase(): string {
|
|
35
46
|
return stripSlash(
|
|
36
|
-
process.env["AILK_API_URL"] ||
|
|
37
|
-
process.env["NEXT_PUBLIC_AILK_API_URL"] ||
|
|
38
|
-
LOCAL_API_ORIGIN,
|
|
47
|
+
process.env["AILK_API_URL"] || browserApiOrigin() || LOCAL_API_ORIGIN,
|
|
39
48
|
);
|
|
40
49
|
}
|
|
41
50
|
|
|
@@ -45,9 +54,7 @@ function apiBase(): string {
|
|
|
45
54
|
*/
|
|
46
55
|
function publicApiBase(): string {
|
|
47
56
|
return stripSlash(
|
|
48
|
-
process.env["
|
|
49
|
-
process.env["AILK_API_URL"] ||
|
|
50
|
-
LOCAL_API_ORIGIN,
|
|
57
|
+
browserApiOrigin() || process.env["AILK_API_URL"] || LOCAL_API_ORIGIN,
|
|
51
58
|
);
|
|
52
59
|
}
|
|
53
60
|
|
|
@@ -58,6 +65,7 @@ function stripSlash(url: string): string {
|
|
|
58
65
|
interface OpenApiDocument {
|
|
59
66
|
openapi: string;
|
|
60
67
|
servers?: Array<{ url?: unknown; description?: unknown }>;
|
|
68
|
+
paths?: Record<string, unknown>;
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
function isOpenApiDocument(value: unknown): value is OpenApiDocument {
|
|
@@ -86,6 +94,61 @@ function withAbsoluteServers(spec: OpenApiDocument): OpenApiDocument {
|
|
|
86
94
|
};
|
|
87
95
|
}
|
|
88
96
|
|
|
97
|
+
/** The operation keys an OpenAPI path item may carry (OpenAPI 3.1 § 4.8.9). */
|
|
98
|
+
const OPERATION_KEYS = new Set([
|
|
99
|
+
"get",
|
|
100
|
+
"put",
|
|
101
|
+
"post",
|
|
102
|
+
"delete",
|
|
103
|
+
"options",
|
|
104
|
+
"head",
|
|
105
|
+
"patch",
|
|
106
|
+
"trace",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The document as a spoke serves it (#7426 D12): this host's own origin as
|
|
111
|
+
* the only server, and only the relayed (path, method) pairs. A path item's
|
|
112
|
+
* non-operation keys (`summary`, `parameters`, ...) ride along with a kept
|
|
113
|
+
* operation; a `servers` list — on the path item or on a kept operation —
|
|
114
|
+
* never does: it would name the hub.
|
|
115
|
+
*/
|
|
116
|
+
function withoutServers(operation: unknown): unknown {
|
|
117
|
+
if (typeof operation !== "object" || operation === null) return operation;
|
|
118
|
+
const { servers: _servers, ...rest } = operation as Record<string, unknown>;
|
|
119
|
+
return rest;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function asSpokeDocument(spec: OpenApiDocument): OpenApiDocument {
|
|
123
|
+
const relayed = new Map<string, Set<string>>();
|
|
124
|
+
for (const { method, path } of RELAY_ROUTES) {
|
|
125
|
+
const methods = relayed.get(path) ?? new Set<string>();
|
|
126
|
+
methods.add(method.toLowerCase());
|
|
127
|
+
relayed.set(path, methods);
|
|
128
|
+
}
|
|
129
|
+
const paths: Record<string, unknown> = {};
|
|
130
|
+
for (const [path, item] of Object.entries(spec.paths ?? {})) {
|
|
131
|
+
const methods = relayed.get(path);
|
|
132
|
+
if (methods === undefined || typeof item !== "object" || item === null) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const kept = Object.fromEntries(
|
|
136
|
+
Object.entries(item)
|
|
137
|
+
.filter(([key]) =>
|
|
138
|
+
OPERATION_KEYS.has(key) ? methods.has(key) : key !== "servers",
|
|
139
|
+
)
|
|
140
|
+
.map(([key, value]) => [
|
|
141
|
+
key,
|
|
142
|
+
OPERATION_KEYS.has(key) ? withoutServers(value) : value,
|
|
143
|
+
]),
|
|
144
|
+
);
|
|
145
|
+
if (Object.keys(kept).some((key) => OPERATION_KEYS.has(key))) {
|
|
146
|
+
paths[path] = kept;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { ...spec, servers: [{ url: stripSlash(resolveBaseUrl()) }], paths };
|
|
150
|
+
}
|
|
151
|
+
|
|
89
152
|
function badGateway(message: string): Response {
|
|
90
153
|
return Response.json(
|
|
91
154
|
{ error: { code: "upstream_unavailable", message } },
|
|
@@ -120,7 +183,9 @@ export async function GET(): Promise<Response> {
|
|
|
120
183
|
return badGateway("The API answered JSON that is not an OpenAPI document.");
|
|
121
184
|
}
|
|
122
185
|
|
|
123
|
-
|
|
186
|
+
const served =
|
|
187
|
+
TOPOLOGY === "spoke" ? asSpokeDocument(spec) : withAbsoluteServers(spec);
|
|
188
|
+
return Response.json(served, {
|
|
124
189
|
status: 200,
|
|
125
190
|
headers: { "cache-control": "public, max-age=3600" },
|
|
126
191
|
});
|
|
@@ -134,13 +134,16 @@ function deferredPrismaForAuth(): PrismaClient {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
|
-
* The
|
|
137
|
+
* The two delegate methods `onPasswordReset` reaches for, named so the call
|
|
138
138
|
* site needs no cast. Method shorthand for the same reason `AuthEmailPrisma`
|
|
139
139
|
* uses it: the generated `PrismaClient` satisfies this structurally, and
|
|
140
|
-
* nothing wider than `verification.deleteMany`
|
|
140
|
+
* nothing wider than `verification.deleteMany` and
|
|
141
|
+
* `oAuthAuthorization.updateMany` is reachable through it. The second ends
|
|
142
|
+
* every credential a spoke holds for the person (#7894 D4, SEC-7894-5).
|
|
141
143
|
*/
|
|
142
144
|
interface VerificationStore {
|
|
143
145
|
verification: { deleteMany(args: unknown): Promise<unknown> };
|
|
146
|
+
oAuthAuthorization: { updateMany(args: unknown): Promise<unknown> };
|
|
144
147
|
}
|
|
145
148
|
|
|
146
149
|
type SocialProviders = {
|
|
@@ -364,6 +367,30 @@ export function createAuth(deps: CreateAuthDeps = {}) {
|
|
|
364
367
|
})}`,
|
|
365
368
|
);
|
|
366
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* A completed reset also ends every credential a SPOKE holds for this
|
|
372
|
+
* person (#7894 D4, SEC-7894-5). A hub session an attacker held minted
|
|
373
|
+
* spoke credentials of its own; recovering the account must end those too,
|
|
374
|
+
* or the attacker keeps reading the person's data on every spoke for the
|
|
375
|
+
* rest of the credential's 24 hours. The same posture as the sibling
|
|
376
|
+
* delete above: the reset ALREADY SUCCEEDED, so a failed revoke is
|
|
377
|
+
* logged (the user id and a reason, never a credential) and never turns
|
|
378
|
+
* it into a 500.
|
|
379
|
+
*/
|
|
380
|
+
try {
|
|
381
|
+
await verificationStore.oAuthAuthorization.updateMany({
|
|
382
|
+
where: { userId: user.id, revokedAt: null },
|
|
383
|
+
data: { revokedAt: new Date() },
|
|
384
|
+
});
|
|
385
|
+
} catch {
|
|
386
|
+
console.warn(
|
|
387
|
+
`[auth-email] ${JSON.stringify({
|
|
388
|
+
userId: user.id,
|
|
389
|
+
kind: "password-reset",
|
|
390
|
+
reason: "spoke_credential_revocation_failed",
|
|
391
|
+
})}`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
367
394
|
/**
|
|
368
395
|
* Guarded like its sibling above, though `lib/auth-email.ts` now also
|
|
369
396
|
* guards its own signing step (#6068). BOTH, deliberately: this callback
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* SignOutTrigger — wraps the signed-in Nav's "Sign out" trigger (#4984).
|
|
5
5
|
*
|
|
6
|
+
* With an `endpoint` (a spoke that signs in through its hub, #7894 D10) it
|
|
7
|
+
* POSTs there — the hub-session sign-out, which revokes the credential and
|
|
8
|
+
* clears the session cookie — and navigates home only when that succeeded;
|
|
9
|
+
* without one it calls Better Auth's client exactly as before.
|
|
10
|
+
*
|
|
6
11
|
* The locale layout (RSC) reads the session and authors the "Sign out" nav
|
|
7
12
|
* action as `kind: "flow"`; Nav hands the rendered trigger `<Button>` back
|
|
8
13
|
* through `renderFlowTrigger` — composed in `SignedInNav`, on the client side
|
|
@@ -22,16 +27,30 @@ export interface SignOutTriggerProps {
|
|
|
22
27
|
children: ReactNode;
|
|
23
28
|
/** Where to land after signing out. Defaults to the site home. */
|
|
24
29
|
homeHref?: string;
|
|
30
|
+
/** A same-origin sign-out route to POST to instead of Better Auth's client. */
|
|
31
|
+
endpoint?: string;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
export function SignOutTrigger({
|
|
28
35
|
children,
|
|
29
36
|
homeHref = "/",
|
|
37
|
+
endpoint,
|
|
30
38
|
}: SignOutTriggerProps): ReactElement {
|
|
31
39
|
const router = useRouter();
|
|
32
40
|
|
|
33
41
|
const handleSignOut = async (): Promise<void> => {
|
|
34
|
-
|
|
42
|
+
if (endpoint !== undefined) {
|
|
43
|
+
// A refused or failed sign-out leaves the visitor signed in, so the
|
|
44
|
+
// page must not pretend otherwise by navigating away.
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch(endpoint, { method: "POST", credentials: "same-origin" });
|
|
47
|
+
if (!res.ok) return;
|
|
48
|
+
} catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
await authClient.signOut();
|
|
53
|
+
}
|
|
35
54
|
router.push(homeHref);
|
|
36
55
|
router.refresh();
|
|
37
56
|
};
|
|
@@ -32,9 +32,19 @@ import { SignOutTrigger } from "./sign-out-trigger";
|
|
|
32
32
|
export type SignedInNavProps = Omit<
|
|
33
33
|
NavProps,
|
|
34
34
|
"navItems" | "renderFlowTrigger" | "translate" | "onSearchOpen"
|
|
35
|
-
|
|
35
|
+
> & {
|
|
36
|
+
/**
|
|
37
|
+
* Where "Sign out" POSTs, from the generated seam's `SIGN_OUT_ENDPOINT`
|
|
38
|
+
* (#7894 D10) — a spoke that signs in through its hub. Absent or `null`,
|
|
39
|
+
* Sign out goes through Better Auth's client, as on a hub.
|
|
40
|
+
*/
|
|
41
|
+
signOutEndpoint?: string | null;
|
|
42
|
+
};
|
|
36
43
|
|
|
37
|
-
export function SignedInNav(
|
|
44
|
+
export function SignedInNav({
|
|
45
|
+
signOutEndpoint,
|
|
46
|
+
...props
|
|
47
|
+
}: SignedInNavProps): ReactElement {
|
|
38
48
|
return (
|
|
39
49
|
<Nav
|
|
40
50
|
{...props}
|
|
@@ -43,7 +53,11 @@ export function SignedInNav(props: SignedInNavProps): ReactElement {
|
|
|
43
53
|
// SignOutTrigger, the account action a non-navigating name label.
|
|
44
54
|
renderFlowTrigger={(action, trigger) =>
|
|
45
55
|
action.labelKey === "signOut" ? (
|
|
46
|
-
<SignOutTrigger
|
|
56
|
+
<SignOutTrigger
|
|
57
|
+
{...(signOutEndpoint ? { endpoint: signOutEndpoint } : {})}
|
|
58
|
+
>
|
|
59
|
+
{trigger}
|
|
60
|
+
</SignOutTrigger>
|
|
47
61
|
) : (
|
|
48
62
|
<span
|
|
49
63
|
className="text-body-sm text-(--color-text-muted)"
|
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
* Next.js instrumentation hook — runs once per server instance at boot.
|
|
3
3
|
*
|
|
4
4
|
* Registers the durable home of a renewed licence key (#6677 DD-6) before any
|
|
5
|
-
* request resolves the licence
|
|
6
|
-
*
|
|
5
|
+
* request resolves the licence, through the generated topology seam (#7426
|
|
6
|
+
* D5): a hub registers the database-backed store; a spoke has no database,
|
|
7
|
+
* so its render registers nothing. Node runtime only: the store reaches the
|
|
8
|
+
* database through Prisma, which the Edge runtime cannot load — the import
|
|
9
|
+
* stays inside the runtime check so the Edge bundle never carries it.
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
export async function register(): Promise<void> {
|
|
10
13
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
|
11
|
-
const {
|
|
12
|
-
await
|
|
14
|
+
const { registerSiteStores } = await import("./lib/topology.generated");
|
|
15
|
+
await registerSiteStores();
|
|
13
16
|
}
|
|
14
17
|
}
|
|
@@ -107,10 +107,12 @@ describe("account-config exports (AC-6)", () => {
|
|
|
107
107
|
expect(Array.isArray(accountForYouSources)).toBe(true);
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
-
it("ships exactly the
|
|
110
|
+
it("ships exactly the base Inbox kinds and the member kinds (#7920), all Recents rows (D6a, D9)", () => {
|
|
111
111
|
expect(accountInboxKinds.map((k) => [k.kind, k.section])).toEqual([
|
|
112
112
|
["result", "updates"],
|
|
113
113
|
["purchase", "updates"],
|
|
114
|
+
["piece", "updates"],
|
|
115
|
+
["message", "updates"],
|
|
114
116
|
]);
|
|
115
117
|
// Nothing in the base area fills Scheduled (the old Upcoming), and no
|
|
116
118
|
// Inbox row asks for a decision — that is the For-you panel's job.
|
|
@@ -118,10 +120,11 @@ describe("account-config exports (AC-6)", () => {
|
|
|
118
120
|
expect(accountInboxKinds.some((k) => k.section === "needs-you")).toBe(false);
|
|
119
121
|
});
|
|
120
122
|
|
|
121
|
-
it("ships email-unverified
|
|
123
|
+
it("ships email-unverified, payment-due and piece-needs-client (#7920) as For-you sources, not Inbox kinds (D9 as amended)", () => {
|
|
122
124
|
expect(accountForYouSources.map((s) => s.id)).toEqual([
|
|
123
125
|
"email-unverified",
|
|
124
126
|
"payment-due",
|
|
127
|
+
"piece-needs-client",
|
|
125
128
|
]);
|
|
126
129
|
const kinds = accountInboxKinds.map((k) => k.kind);
|
|
127
130
|
expect(kinds).not.toContain("email-unverified");
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isClosingFooterPath,
|
|
3
|
+
normalizeRoutePath,
|
|
4
|
+
} from "../closing-footer-path";
|
|
5
|
+
|
|
6
|
+
const LOCALES = ["en", "es"];
|
|
7
|
+
|
|
8
|
+
describe("normalizeRoutePath (#7797)", () => {
|
|
9
|
+
it.each([
|
|
10
|
+
["/about", "/about"],
|
|
11
|
+
["/about/", "/about"],
|
|
12
|
+
["/about//", "/about"],
|
|
13
|
+
["/es/about", "/about"],
|
|
14
|
+
["/es/about/", "/about"],
|
|
15
|
+
["/about?x=1", "/about"],
|
|
16
|
+
["/about#team", "/about"],
|
|
17
|
+
["/services/first-engagement/", "/services/first-engagement"],
|
|
18
|
+
["/", "/"],
|
|
19
|
+
["", "/"],
|
|
20
|
+
["/es", "/"],
|
|
21
|
+
["/es/", "/"],
|
|
22
|
+
])("%s → %s", (input, expected) => {
|
|
23
|
+
expect(normalizeRoutePath(input, LOCALES)).toBe(expected);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("strips only a whole leading locale segment", () => {
|
|
27
|
+
expect(normalizeRoutePath("/espresso", LOCALES)).toBe("/espresso");
|
|
28
|
+
expect(normalizeRoutePath("/about/es", LOCALES)).toBe("/about/es");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("strips no prefix when no locale is enabled", () => {
|
|
32
|
+
expect(normalizeRoutePath("/es/about", [])).toBe("/es/about");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("isClosingFooterPath (#7797)", () => {
|
|
37
|
+
const paths = ["/about", "/services/first-engagement/"];
|
|
38
|
+
|
|
39
|
+
it.each([
|
|
40
|
+
"/about",
|
|
41
|
+
"/about/",
|
|
42
|
+
"/es/about",
|
|
43
|
+
"/es/about/",
|
|
44
|
+
"/services/first-engagement",
|
|
45
|
+
])("%s is a listed page", (pathname) => {
|
|
46
|
+
expect(isClosingFooterPath({ pathname, paths, locales: LOCALES })).toBe(
|
|
47
|
+
true,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it.each(["/services", "/about/team", "/aboutus", "/privacy", "/es/contact"])(
|
|
52
|
+
"%s is not a listed page",
|
|
53
|
+
(pathname) => {
|
|
54
|
+
expect(isClosingFooterPath({ pathname, paths, locales: LOCALES })).toBe(
|
|
55
|
+
false,
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
it("matches a listed path spelled with a locale prefix or a trailing slash", () => {
|
|
61
|
+
expect(
|
|
62
|
+
isClosingFooterPath({
|
|
63
|
+
pathname: "/about",
|
|
64
|
+
paths: ["/es/about/"],
|
|
65
|
+
locales: LOCALES,
|
|
66
|
+
}),
|
|
67
|
+
).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("is false with no list, an empty list, or no pathname", () => {
|
|
71
|
+
expect(
|
|
72
|
+
isClosingFooterPath({
|
|
73
|
+
pathname: "/about",
|
|
74
|
+
paths: undefined,
|
|
75
|
+
locales: LOCALES,
|
|
76
|
+
}),
|
|
77
|
+
).toBe(false);
|
|
78
|
+
expect(
|
|
79
|
+
isClosingFooterPath({ pathname: "/about", paths: [], locales: LOCALES }),
|
|
80
|
+
).toBe(false);
|
|
81
|
+
expect(
|
|
82
|
+
isClosingFooterPath({ pathname: null, paths, locales: LOCALES }),
|
|
83
|
+
).toBe(false);
|
|
84
|
+
expect(isClosingFooterPath({ pathname: "", paths, locales: LOCALES })).toBe(
|
|
85
|
+
false,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("never selects the home, even when it is listed", () => {
|
|
90
|
+
for (const pathname of ["/", "/es", "/es/"]) {
|
|
91
|
+
expect(
|
|
92
|
+
isClosingFooterPath({ pathname, paths: ["/"], locales: LOCALES }),
|
|
93
|
+
).toBe(false);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|