create-ailk 0.15.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/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 +42 -0
- package/dist/spoke-checkout.js +359 -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 +449 -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 +15 -0
- 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 +27 -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/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
|
@@ -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
|
+
});
|
|
@@ -513,6 +513,33 @@ describe("hardening — what the site serves, and a missing hub address", () =>
|
|
|
513
513
|
expect(res.status).toBe(201);
|
|
514
514
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
515
515
|
});
|
|
516
|
+
|
|
517
|
+
// #7874 D12 — the fallback reads the browser API origin seam. The
|
|
518
|
+
// committed seam is the hub render, so this case runs it unmocked.
|
|
519
|
+
it("on a hub, NEXT_PUBLIC_AILK_API_URL alone still counts as a hub address", async () => {
|
|
520
|
+
delete process.env.AILK_API_URL;
|
|
521
|
+
process.env.NEXT_PUBLIC_AILK_API_URL = HUB;
|
|
522
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
523
|
+
expect(res.status).toBe(201);
|
|
524
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("on a spoke (the seam's spoke render), that fallback is skipped", async () => {
|
|
528
|
+
delete process.env.AILK_API_URL;
|
|
529
|
+
process.env.NEXT_PUBLIC_AILK_API_URL = HUB;
|
|
530
|
+
let res: Response | undefined;
|
|
531
|
+
await jest.isolateModulesAsync(async () => {
|
|
532
|
+
jest.doMock("../api-origin.generated", () => ({
|
|
533
|
+
browserApiOrigin: (): string | undefined => undefined,
|
|
534
|
+
}));
|
|
535
|
+
const spoke = await import("../relay");
|
|
536
|
+
res = await spoke.handleRelay(relayRequest("/v1/leads/submit"));
|
|
537
|
+
});
|
|
538
|
+
if (res === undefined) throw new Error("the spoke relay did not answer");
|
|
539
|
+
expect(res.status).toBe(502);
|
|
540
|
+
expect(await errorCode(res)).toBe("relay_unreachable");
|
|
541
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
542
|
+
});
|
|
516
543
|
});
|
|
517
544
|
});
|
|
518
545
|
|
|
@@ -32,22 +32,41 @@
|
|
|
32
32
|
// failure rejects — a 401/403 for a verified email included — so the area
|
|
33
33
|
// shows an error row with Retry instead of an empty Inbox (D12). Scheduled
|
|
34
34
|
// (the old Upcoming) stays empty in the base area; a module fills it.
|
|
35
|
+
//
|
|
36
|
+
// The member area's spoke leg (#7920) — the client's own records on the hub,
|
|
37
|
+
// read by a spoke that signs its clients in through the hub (#7877, #7894):
|
|
38
|
+
//
|
|
39
|
+
// - Inbox kinds `piece` (`GET /v1/member/pieces`) and `message`
|
|
40
|
+
// (`GET /v1/member/messages`). A message with a file has a file action to
|
|
41
|
+
// this site's own member file read, `account/inbox/message/[id]/file`,
|
|
42
|
+
// which re-serves the hub's bytes — the browser holds no hub credential.
|
|
43
|
+
// - For-you source `piece-needs-client` — each piece waiting on the client,
|
|
44
|
+
// with an Open action to its Inbox row. It shares the pieces read.
|
|
45
|
+
//
|
|
46
|
+
// The hub admits these reads only with the client's hub credential, which
|
|
47
|
+
// `ctx.init` carries as `Authorization` on such a spoke and nowhere else. On a
|
|
48
|
+
// cookie-session site (a hub, or a site with its own sign-in) there is none,
|
|
49
|
+
// so the three make no call and load `[]` — inert, never an error row.
|
|
35
50
|
|
|
36
51
|
import {
|
|
37
52
|
flowCheckouts,
|
|
53
|
+
member,
|
|
38
54
|
subscription,
|
|
39
55
|
waitlistSignups,
|
|
40
56
|
type FlowCheckoutMeItem,
|
|
41
57
|
type WaitlistSignupMeResponse,
|
|
42
58
|
} from "@working-theory/api-client";
|
|
43
59
|
import type {
|
|
60
|
+
AccountContext,
|
|
44
61
|
AccountForYouSource,
|
|
45
62
|
AccountInboxKind,
|
|
46
63
|
AccountSettingsSection,
|
|
47
64
|
AccountUser,
|
|
48
65
|
} from "@working-theory/templates/account";
|
|
49
66
|
import { localeHref } from "@working-theory/ui";
|
|
67
|
+
import type { MemberMessage, MemberPiece } from "@working-theory/validation";
|
|
50
68
|
import { formatMoney, formatNumber } from "@working-theory/web-i18n";
|
|
69
|
+
import { cache } from "react";
|
|
51
70
|
|
|
52
71
|
import { unwrapApiResult } from "./server-api";
|
|
53
72
|
|
|
@@ -168,6 +187,92 @@ const purchase: AccountInboxKind<FlowCheckoutMeItem> = {
|
|
|
168
187
|
},
|
|
169
188
|
};
|
|
170
189
|
|
|
190
|
+
// ── Member Inbox kinds (#7920) ───────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/** Whether `init` carries the client's hub credential (see the header). */
|
|
193
|
+
function hasHubCredential({ init }: AccountContext): boolean {
|
|
194
|
+
return Object.keys(init.headers).some((name) => name.toLowerCase() === "authorization");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** One pieces read per request — the `piece` kind and the For-you source share it. */
|
|
198
|
+
const memberPieces = cache((init: AccountContext["init"]) => member.pieces(init));
|
|
199
|
+
|
|
200
|
+
async function loadPieces(ctx: AccountContext): Promise<MemberPiece[]> {
|
|
201
|
+
if (!hasHubCredential(ctx)) return [];
|
|
202
|
+
return (await read(memberPieces(ctx.init), ctx.user))?.items ?? [];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const piece: AccountInboxKind<MemberPiece> = {
|
|
206
|
+
kind: "piece",
|
|
207
|
+
label: "pieces.heading",
|
|
208
|
+
section: "updates",
|
|
209
|
+
async load(ctx) {
|
|
210
|
+
return (await loadPieces(ctx)).map((p) => ({
|
|
211
|
+
id: p.id,
|
|
212
|
+
title: p.title,
|
|
213
|
+
at: p.updatedAt,
|
|
214
|
+
data: p,
|
|
215
|
+
}));
|
|
216
|
+
},
|
|
217
|
+
renderDetail({ data }, { locale, t }) {
|
|
218
|
+
return {
|
|
219
|
+
status: data.stage,
|
|
220
|
+
...(data.needsClient ? { lead: t("pieces.needsClient") } : {}),
|
|
221
|
+
fields: {
|
|
222
|
+
heading: t("pieces.heading"),
|
|
223
|
+
items: [
|
|
224
|
+
{ label: t("pieces.stage"), value: data.stage },
|
|
225
|
+
{ label: t("pieces.createdAt"), value: formatWhen(data.createdAt, locale) },
|
|
226
|
+
{ label: t("pieces.updatedAt"), value: formatWhen(data.updatedAt, locale) },
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const message: AccountInboxKind<MemberMessage> = {
|
|
234
|
+
kind: "message",
|
|
235
|
+
label: "messages.heading",
|
|
236
|
+
section: "updates",
|
|
237
|
+
async load(ctx) {
|
|
238
|
+
if (!hasHubCredential(ctx)) return [];
|
|
239
|
+
const messages = (await read(member.messages(ctx.init), ctx.user))?.items ?? [];
|
|
240
|
+
return messages.map((m) => ({
|
|
241
|
+
id: m.id,
|
|
242
|
+
title: m.subject,
|
|
243
|
+
at: m.createdAt,
|
|
244
|
+
data: m,
|
|
245
|
+
}));
|
|
246
|
+
},
|
|
247
|
+
renderDetail({ data }, { locale, t }) {
|
|
248
|
+
return {
|
|
249
|
+
lead: data.body,
|
|
250
|
+
...(data.file
|
|
251
|
+
? {
|
|
252
|
+
actions: [
|
|
253
|
+
{
|
|
254
|
+
key: "file",
|
|
255
|
+
label: t("messages.download", { filename: data.file.filename }),
|
|
256
|
+
variant: "default" as const,
|
|
257
|
+
href: localeHref(
|
|
258
|
+
locale,
|
|
259
|
+
`/account/inbox/message/${encodeURIComponent(data.id)}/file`,
|
|
260
|
+
),
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
}
|
|
264
|
+
: {}),
|
|
265
|
+
fields: {
|
|
266
|
+
heading: t("messages.heading"),
|
|
267
|
+
items: [
|
|
268
|
+
{ label: t("messages.sentAt"), value: formatWhen(data.createdAt, locale) },
|
|
269
|
+
...(data.file ? [{ label: t("messages.file"), value: data.file.filename }] : []),
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
|
|
171
276
|
// ── Base For-you sources (D9 as amended, D8a) ────────────────────────────────
|
|
172
277
|
|
|
173
278
|
const emailUnverified: AccountForYouSource = {
|
|
@@ -206,10 +311,33 @@ const paymentDue: AccountForYouSource = {
|
|
|
206
311
|
},
|
|
207
312
|
};
|
|
208
313
|
|
|
314
|
+
// ── Member For-you source (#7920) ────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
const pieceNeedsClient: AccountForYouSource = {
|
|
317
|
+
id: "piece-needs-client",
|
|
318
|
+
async load(ctx) {
|
|
319
|
+
const { locale, t } = ctx;
|
|
320
|
+
return (await loadPieces(ctx))
|
|
321
|
+
.filter((p) => p.needsClient)
|
|
322
|
+
.map((p) => ({
|
|
323
|
+
id: `piece:${p.id}`,
|
|
324
|
+
type: { id: "piece", label: t("pieces.type"), tone: "needs-you" },
|
|
325
|
+
title: p.title,
|
|
326
|
+
// The read carries no time the piece began waiting — its last update.
|
|
327
|
+
time: { kind: "at", at: p.updatedAt },
|
|
328
|
+
action: {
|
|
329
|
+
kind: "open",
|
|
330
|
+
href: localeHref(locale, `/account/inbox/piece/${encodeURIComponent(p.id)}`),
|
|
331
|
+
},
|
|
332
|
+
dismissible: true,
|
|
333
|
+
}));
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
|
|
209
337
|
// ── Contributions ─────────────────────────────────────────────────────────────
|
|
210
338
|
|
|
211
339
|
/** Inbox item kinds, in load order. A module appends its own. */
|
|
212
|
-
export const accountInboxKinds: AccountInboxKind[] = [result, purchase];
|
|
340
|
+
export const accountInboxKinds: AccountInboxKind[] = [result, purchase, piece, message];
|
|
213
341
|
|
|
214
342
|
/** User-settings sections a module adds after the base ones. None in the base area. */
|
|
215
343
|
export const accountSettingsSections: AccountSettingsSection[] = [];
|
|
@@ -218,4 +346,5 @@ export const accountSettingsSections: AccountSettingsSection[] = [];
|
|
|
218
346
|
export const accountForYouSources: AccountForYouSource[] = [
|
|
219
347
|
emailUnverified,
|
|
220
348
|
paymentDue,
|
|
349
|
+
pieceNeedsClient,
|
|
221
350
|
];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// api-origin.generated.ts — the browser-facing API origin this site's server reads.
|
|
2
|
+
//
|
|
3
|
+
// GENERATED by create-ailk for this site's topology (#7874). Do not edit:
|
|
4
|
+
// every scaffold rewrites it. A hub reads NEXT_PUBLIC_AILK_API_URL as a
|
|
5
|
+
// fallback API origin; a spoke (`--topology spoke`) has no browser-facing
|
|
6
|
+
// API origin — its hub origin is server-only (AILK_API_URL) — so its render
|
|
7
|
+
// reads no environment variable.
|
|
8
|
+
|
|
9
|
+
export function browserApiOrigin(): string | undefined {
|
|
10
|
+
return process.env.NEXT_PUBLIC_AILK_API_URL;
|
|
11
|
+
}
|