create-ailk 0.15.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/README.md +124 -0
- package/dist/add-module.js +52 -7
- package/dist/add-route.js +8 -0
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/cli.js +82 -2
- package/dist/derive-architecture.d.ts +22 -0
- package/dist/derive-architecture.js +58 -1
- 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 +9 -0
- package/dist/lib/extract-module-bundle.js +18 -1
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.js +76 -0
- package/dist/parse-args.d.ts +5 -0
- package/dist/parse-args.js +17 -1
- package/dist/programmatic.d.ts +8 -0
- package/dist/programmatic.js +78 -9
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-manifest.d.ts +43 -0
- package/dist/site-manifest.js +98 -3
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +67 -0
- package/dist/spoke-checkout.js +562 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +23 -8
- package/dist/topology.d.ts +124 -9
- package/dist/topology.js +451 -40
- package/package.json +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +19 -4
- 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 +89 -0
- 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__/spoke-store.test.ts +20 -0
- 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/spoke-store.ts +25 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/auth.ts +126 -0
- 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/leads/__tests__/index.test.ts +7 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +72 -1
- 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 +293 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +131 -2
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/web/.env.example +58 -5
- 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]/layout.tsx +28 -15
- 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 +57 -24
- package/templates/apps/web/app/openapi.json/route.ts +3 -6
- 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/lib/__tests__/account-config.test.ts +5 -2
- 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 +33 -0
- package/templates/apps/web/lib/__tests__/server-api.test.ts +37 -28
- 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/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 +3 -1
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/topology.generated.ts +15 -5
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/database/CHANGELOG.md +26 -0
- package/templates/database/__tests__/agents.db.test.ts +25 -5
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -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 +3 -2
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +98 -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 -35
|
@@ -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,16 +100,35 @@ describe("GET /openapi.json", () => {
|
|
|
93
100
|
expect(requestedUrl()).toBe("http://api.internal:4000/v1/openapi.json");
|
|
94
101
|
});
|
|
95
102
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
+
);
|
|
100
118
|
|
|
101
|
-
|
|
119
|
+
// #7874 D12 — the hub case above runs the committed (hub) render of the
|
|
120
|
+
// browser API origin seam unmocked; the spoke render reads no env at all.
|
|
121
|
+
it("skips the NEXT_PUBLIC_AILK_API_URL fallback under the seam's spoke render", async () => {
|
|
102
122
|
process.env["NEXT_PUBLIC_AILK_API_URL"] = "https://api.example.com/";
|
|
103
123
|
apiAnswers(200, JSON.stringify(SPEC));
|
|
104
|
-
await
|
|
105
|
-
|
|
124
|
+
await jest.isolateModulesAsync(async () => {
|
|
125
|
+
jest.doMock("../../../lib/api-origin.generated", () => ({
|
|
126
|
+
browserApiOrigin: (): string | undefined => undefined,
|
|
127
|
+
}));
|
|
128
|
+
const spoke = await import("../route");
|
|
129
|
+
await spoke.GET();
|
|
130
|
+
});
|
|
131
|
+
expect(requestedUrl()).toBe("http://localhost:3001/v1/openapi.json");
|
|
106
132
|
});
|
|
107
133
|
|
|
108
134
|
it("answers 502, not 404, when the API cannot be reached", async () => {
|
|
@@ -126,23 +152,30 @@ describe("GET /openapi.json", () => {
|
|
|
126
152
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
127
153
|
});
|
|
128
154
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
+
);
|
|
146
179
|
|
|
147
180
|
it("resolves a relative servers[] URL against AILK_API_URL when no public origin is set", async () => {
|
|
148
181
|
process.env["AILK_API_URL"] = "http://localhost:3700";
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* host, and never calls a path this site would answer 404.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
import { browserApiOrigin } from "../../lib/api-origin.generated";
|
|
26
27
|
import { RELAY_ROUTES } from "../../lib/relay";
|
|
27
28
|
import { TOPOLOGY } from "../../lib/topology.generated";
|
|
28
29
|
import { resolveBaseUrl } from "../../src/lib/routes";
|
|
@@ -43,9 +44,7 @@ const LOCAL_API_ORIGIN = "http://localhost:3001";
|
|
|
43
44
|
*/
|
|
44
45
|
function apiBase(): string {
|
|
45
46
|
return stripSlash(
|
|
46
|
-
process.env["AILK_API_URL"] ||
|
|
47
|
-
process.env["NEXT_PUBLIC_AILK_API_URL"] ||
|
|
48
|
-
LOCAL_API_ORIGIN,
|
|
47
|
+
process.env["AILK_API_URL"] || browserApiOrigin() || LOCAL_API_ORIGIN,
|
|
49
48
|
);
|
|
50
49
|
}
|
|
51
50
|
|
|
@@ -55,9 +54,7 @@ function apiBase(): string {
|
|
|
55
54
|
*/
|
|
56
55
|
function publicApiBase(): string {
|
|
57
56
|
return stripSlash(
|
|
58
|
-
process.env["
|
|
59
|
-
process.env["AILK_API_URL"] ||
|
|
60
|
-
LOCAL_API_ORIGIN,
|
|
57
|
+
browserApiOrigin() || process.env["AILK_API_URL"] || LOCAL_API_ORIGIN,
|
|
61
58
|
);
|
|
62
59
|
}
|
|
63
60
|
|
|
@@ -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)"
|
|
@@ -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,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one reader of the listing site key (#7872 D8, AC-6).
|
|
3
|
+
*
|
|
4
|
+
* `NEXT_PUBLIC_AILK_LISTING_SITE_KEY` wins; `AILK_LISTING_PUBLIC_KEY` is the
|
|
5
|
+
* fallback every site configured since create-ailk 0.12.0 already sets; an
|
|
6
|
+
* empty or whitespace value counts as unset, so the listicle directory stays
|
|
7
|
+
* off rather than reading the hub with a blank key.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { listingSiteKey, listingsEnabled } from "../listings-config";
|
|
13
|
+
|
|
14
|
+
const PUBLIC_NAME = "NEXT_PUBLIC_AILK_LISTING_SITE_KEY";
|
|
15
|
+
const FALLBACK_NAME = "AILK_LISTING_PUBLIC_KEY";
|
|
16
|
+
|
|
17
|
+
const original = {
|
|
18
|
+
public: process.env[PUBLIC_NAME],
|
|
19
|
+
fallback: process.env[FALLBACK_NAME],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function setEnv(name: string, value: string | undefined): void {
|
|
23
|
+
if (value === undefined) delete process.env[name];
|
|
24
|
+
else process.env[name] = value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
setEnv(PUBLIC_NAME, original.public);
|
|
29
|
+
setEnv(FALLBACK_NAME, original.fallback);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("listingSiteKey / listingsEnabled", () => {
|
|
33
|
+
it("answers the trimmed public name when both are set", () => {
|
|
34
|
+
setEnv(PUBLIC_NAME, " site-public ");
|
|
35
|
+
setEnv(FALLBACK_NAME, "site-fallback");
|
|
36
|
+
|
|
37
|
+
expect(listingSiteKey()).toBe("site-public");
|
|
38
|
+
expect(listingsEnabled()).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("answers the trimmed fallback when only it is set", () => {
|
|
42
|
+
setEnv(PUBLIC_NAME, undefined);
|
|
43
|
+
setEnv(FALLBACK_NAME, " site-fallback\n");
|
|
44
|
+
|
|
45
|
+
expect(listingSiteKey()).toBe("site-fallback");
|
|
46
|
+
expect(listingsEnabled()).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("falls back when the public name is whitespace only", () => {
|
|
50
|
+
setEnv(PUBLIC_NAME, " ");
|
|
51
|
+
setEnv(FALLBACK_NAME, "site-fallback");
|
|
52
|
+
|
|
53
|
+
expect(listingSiteKey()).toBe("site-fallback");
|
|
54
|
+
expect(listingsEnabled()).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("answers undefined when neither is set", () => {
|
|
58
|
+
setEnv(PUBLIC_NAME, undefined);
|
|
59
|
+
setEnv(FALLBACK_NAME, undefined);
|
|
60
|
+
|
|
61
|
+
expect(listingSiteKey()).toBeUndefined();
|
|
62
|
+
expect(listingsEnabled()).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("answers undefined when both are empty or whitespace", () => {
|
|
66
|
+
setEnv(PUBLIC_NAME, "");
|
|
67
|
+
setEnv(FALLBACK_NAME, " \t");
|
|
68
|
+
|
|
69
|
+
expect(listingSiteKey()).toBeUndefined();
|
|
70
|
+
expect(listingsEnabled()).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("apps/web/.env.example", () => {
|
|
75
|
+
it("documents both names", () => {
|
|
76
|
+
const example = readFileSync(
|
|
77
|
+
join(__dirname, "..", "..", ".env.example"),
|
|
78
|
+
"utf8",
|
|
79
|
+
);
|
|
80
|
+
expect(example).toMatch(new RegExp(`^# ${PUBLIC_NAME}=`, "m"));
|
|
81
|
+
expect(example).toMatch(new RegExp(`^# ${FALLBACK_NAME}=`, "m"));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -294,6 +294,55 @@ describe("no read without a key or the paid package", () => {
|
|
|
294
294
|
});
|
|
295
295
|
});
|
|
296
296
|
|
|
297
|
+
describe("the default key reader is listings-config's (#7872 D8)", () => {
|
|
298
|
+
const PUBLIC_NAME = "NEXT_PUBLIC_AILK_LISTING_SITE_KEY";
|
|
299
|
+
const FALLBACK_NAME = "AILK_LISTING_PUBLIC_KEY";
|
|
300
|
+
const original = { public: process.env[PUBLIC_NAME], fallback: process.env[FALLBACK_NAME] };
|
|
301
|
+
|
|
302
|
+
function setEnv(name: string, value: string | undefined): void {
|
|
303
|
+
if (value === undefined) delete process.env[name];
|
|
304
|
+
else process.env[name] = value;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Every dependency but the key: the extension falls back to its own reader. */
|
|
308
|
+
function keylessDepsExtension() {
|
|
309
|
+
return createListingExtension({
|
|
310
|
+
pathname: async () => path,
|
|
311
|
+
locales: () => ["en", "fr"],
|
|
312
|
+
fetchDirectory,
|
|
313
|
+
loadUi: async () => STUB_UI,
|
|
314
|
+
translate: async () => (key: string) => `listings.${key}`,
|
|
315
|
+
baseUrl: () => "https://site.test",
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
afterEach(() => {
|
|
320
|
+
setEnv(PUBLIC_NAME, original.public);
|
|
321
|
+
setEnv(FALLBACK_NAME, original.fallback);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("reads the directory with NEXT_PUBLIC_AILK_LISTING_SITE_KEY", async () => {
|
|
325
|
+
setEnv(PUBLIC_NAME, "k");
|
|
326
|
+
setEnv(FALLBACK_NAME, undefined);
|
|
327
|
+
path = "/en/partners";
|
|
328
|
+
|
|
329
|
+
await keylessDepsExtension().appendix!({ type: "AboutPage", slug: "partners", locale: "en" });
|
|
330
|
+
|
|
331
|
+
expect(fetchDirectory).toHaveBeenCalledWith("k", "/partners", "en");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("makes no read with neither variable set", async () => {
|
|
335
|
+
setEnv(PUBLIC_NAME, undefined);
|
|
336
|
+
setEnv(FALLBACK_NAME, undefined);
|
|
337
|
+
path = "/en/partners";
|
|
338
|
+
|
|
339
|
+
expect(
|
|
340
|
+
await keylessDepsExtension().appendix!({ type: "AboutPage", slug: "partners", locale: "en" }),
|
|
341
|
+
).toBeNull();
|
|
342
|
+
expect(fetchDirectory).toHaveBeenCalledTimes(0);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
297
346
|
describe("a failed read renders the page as today", () => {
|
|
298
347
|
let warn: jest.SpyInstance;
|
|
299
348
|
beforeEach(() => {
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// oss: false
|
|
2
2
|
/**
|
|
3
3
|
* Tests for enabledMeetingSlugs (#6183 S9b D5) — the D4-resolving read both
|
|
4
|
-
* /meet pages share.
|
|
4
|
+
* /meet pages share — and its spoke branch (#7872 D4).
|
|
5
|
+
*
|
|
6
|
+
* The topology seam is mocked: its hub render imports `../auth`, which this
|
|
7
|
+
* suite has no reason to load (`app/openapi.json/__tests__/route.test.ts`'s
|
|
8
|
+
* pattern).
|
|
5
9
|
*/
|
|
6
10
|
const mockMeetingTypes = jest.fn();
|
|
7
11
|
jest.mock("@working-theory/api-client", () => ({
|
|
@@ -9,6 +13,14 @@ jest.mock("@working-theory/api-client", () => ({
|
|
|
9
13
|
schedule: { meetingTypes: (...args: unknown[]) => mockMeetingTypes(...args) },
|
|
10
14
|
}));
|
|
11
15
|
|
|
16
|
+
const mockTopology: { value: "hub" | "spoke" } = { value: "hub" };
|
|
17
|
+
// 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.
|
|
18
|
+
jest.mock("../topology.generated", () => ({
|
|
19
|
+
get TOPOLOGY() {
|
|
20
|
+
return mockTopology.value;
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
|
|
12
24
|
import { enabledMeetingSlugs } from "../meeting-slugs";
|
|
13
25
|
|
|
14
26
|
describe("enabledMeetingSlugs", () => {
|
|
@@ -16,6 +28,7 @@ describe("enabledMeetingSlugs", () => {
|
|
|
16
28
|
|
|
17
29
|
beforeEach(() => {
|
|
18
30
|
mockMeetingTypes.mockReset();
|
|
31
|
+
mockTopology.value = "hub";
|
|
19
32
|
});
|
|
20
33
|
|
|
21
34
|
afterEach(() => {
|
|
@@ -70,3 +83,38 @@ describe("enabledMeetingSlugs", () => {
|
|
|
70
83
|
await expect(enabledMeetingSlugs()).resolves.toEqual([]);
|
|
71
84
|
});
|
|
72
85
|
});
|
|
86
|
+
|
|
87
|
+
describe("enabledMeetingSlugs on a spoke (#7872 D4)", () => {
|
|
88
|
+
const originalEnv = process.env["SCHEDULING_CAL_LINK"];
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
mockMeetingTypes.mockReset();
|
|
92
|
+
mockTopology.value = "spoke";
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
mockTopology.value = "hub";
|
|
97
|
+
if (originalEnv === undefined) delete process.env["SCHEDULING_CAL_LINK"];
|
|
98
|
+
else process.env["SCHEDULING_CAL_LINK"] = originalEnv;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("null + SCHEDULING_CAL_LINK set → [], never the fallback: the hub books only a stored slug", async () => {
|
|
102
|
+
process.env["SCHEDULING_CAL_LINK"] = "acme/intro-call";
|
|
103
|
+
mockMeetingTypes.mockResolvedValue({ ok: true, body: { slugs: null } });
|
|
104
|
+
|
|
105
|
+
await expect(enabledMeetingSlugs()).resolves.toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("passes the workspace's stored list through unchanged", async () => {
|
|
109
|
+
mockMeetingTypes.mockResolvedValue({ ok: true, body: { slugs: ["intro"] } });
|
|
110
|
+
|
|
111
|
+
await expect(enabledMeetingSlugs()).resolves.toEqual(["intro"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("a non-ok API result → [], fail closed", async () => {
|
|
115
|
+
process.env["SCHEDULING_CAL_LINK"] = "acme/intro-call";
|
|
116
|
+
mockMeetingTypes.mockResolvedValue({ ok: false, status: 401, body: null });
|
|
117
|
+
|
|
118
|
+
await expect(enabledMeetingSlugs()).resolves.toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -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";
|
|
@@ -513,6 +514,38 @@ describe("hardening — what the site serves, and a missing hub address", () =>
|
|
|
513
514
|
expect(res.status).toBe(201);
|
|
514
515
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
515
516
|
});
|
|
517
|
+
|
|
518
|
+
// #7874 D12 — the fallback reads the browser API origin seam. The
|
|
519
|
+
// committed seam is the hub render, so this case runs it unmocked.
|
|
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
|
+
);
|
|
532
|
+
|
|
533
|
+
it("on a spoke (the seam's spoke render), that fallback is skipped", async () => {
|
|
534
|
+
delete process.env.AILK_API_URL;
|
|
535
|
+
process.env.NEXT_PUBLIC_AILK_API_URL = HUB;
|
|
536
|
+
let res: Response | undefined;
|
|
537
|
+
await jest.isolateModulesAsync(async () => {
|
|
538
|
+
jest.doMock("../api-origin.generated", () => ({
|
|
539
|
+
browserApiOrigin: (): string | undefined => undefined,
|
|
540
|
+
}));
|
|
541
|
+
const spoke = await import("../relay");
|
|
542
|
+
res = await spoke.handleRelay(relayRequest("/v1/leads/submit"));
|
|
543
|
+
});
|
|
544
|
+
if (res === undefined) throw new Error("the spoke relay did not answer");
|
|
545
|
+
expect(res.status).toBe(502);
|
|
546
|
+
expect(await errorCode(res)).toBe("relay_unreachable");
|
|
547
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
548
|
+
});
|
|
516
549
|
});
|
|
517
550
|
});
|
|
518
551
|
|
|
@@ -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
|
});
|