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
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account area on a spoke that signs in through its hub (#7894 D10, D12;
|
|
3
|
+
* spec docs/specs/7894-the-hub-signs-in-its-spec.md AC-5, AC-7).
|
|
4
|
+
*
|
|
5
|
+
* The generated web seam is mocked to its `signIn: "hub"` render: the
|
|
6
|
+
* session comes from `readSession`, `forwardedCookieInit()` re-targets to the
|
|
7
|
+
* hub credential, and `SIGN_IN_PROVIDER` is `"hub"`. It sits under
|
|
8
|
+
* `account/__tests__`, which a hub-mode spoke drops with the other account
|
|
9
|
+
* suites (spec 7894 D9), so it runs in this monorepo only.
|
|
10
|
+
*
|
|
11
|
+
* Covers:
|
|
12
|
+
* - AC-7: with the hub answering 404 to the base reads (the hub credential
|
|
13
|
+
* is admitted on none of them, D6), `loadInboxItems` loads `[]` for
|
|
14
|
+
* `result` and `purchase` with `failed` false — an empty Inbox, never an
|
|
15
|
+
* error row — and every read carries the Bearer credential, not a cookie.
|
|
16
|
+
* - AC-5: the settings page renders no `NameForm` and no deletion request,
|
|
17
|
+
* and links to `<AILK_HUB_SIGN_IN_URL>/account/settings` instead; Billing
|
|
18
|
+
* is absent (its read answers 404).
|
|
19
|
+
*/
|
|
20
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
import { render, screen } from "@testing-library/react";
|
|
25
|
+
|
|
26
|
+
jest.mock("next-intl/server", () => {
|
|
27
|
+
const msgs: unknown = require("../../../../../messages/en.json");
|
|
28
|
+
const dig = (obj: unknown, path: string): unknown =>
|
|
29
|
+
path.split(".").reduce<unknown>(
|
|
30
|
+
(acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
|
|
31
|
+
obj,
|
|
32
|
+
);
|
|
33
|
+
return {
|
|
34
|
+
__esModule: true,
|
|
35
|
+
getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
|
|
36
|
+
const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
|
|
37
|
+
const messages = dig(msgs, ns);
|
|
38
|
+
return (key: string): string => {
|
|
39
|
+
const value = dig(messages, key);
|
|
40
|
+
return typeof value === "string" ? value : `${ns}.${key}`;
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const mockHeaders = jest.fn();
|
|
47
|
+
jest.mock("next/headers", () => ({
|
|
48
|
+
__esModule: true,
|
|
49
|
+
headers: (...args: unknown[]) => mockHeaders(...args),
|
|
50
|
+
cookies: jest.fn().mockResolvedValue({ toString: () => "" }),
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
const mockReadSession = jest.fn();
|
|
54
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the seam is generated per build (#7894 D8); its `signIn: "hub"` render is the boundary under test, and no env or HTTP surface selects it.
|
|
55
|
+
jest.mock("../../../../../lib/topology.generated", () => ({
|
|
56
|
+
__esModule: true,
|
|
57
|
+
TOPOLOGY: "spoke",
|
|
58
|
+
SIGN_IN_ENABLED: true,
|
|
59
|
+
SIGN_IN_PROVIDER: "hub",
|
|
60
|
+
SIGN_IN_PATH: "/account",
|
|
61
|
+
SIGN_UP_PATH: null,
|
|
62
|
+
SIGN_OUT_ENDPOINT: "/api/hub-session/sign-out",
|
|
63
|
+
readSession: (...args: unknown[]) => mockReadSession(...args),
|
|
64
|
+
registerSiteStores: () => Promise.resolve(),
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
const mockLeadsSubmit = jest.fn();
|
|
68
|
+
const notFound = { ok: false, status: 404, body: { ok: false, error: { code: "not_found", message: "Not found" } } };
|
|
69
|
+
const mockWaitlistMe = jest.fn();
|
|
70
|
+
const mockFlowCheckoutsMe = jest.fn();
|
|
71
|
+
const mockSubscriptionMe = jest.fn();
|
|
72
|
+
const mockMemberRead = jest.fn();
|
|
73
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
74
|
+
__esModule: true,
|
|
75
|
+
waitlistSignups: { me: (...args: unknown[]) => mockWaitlistMe(...args) },
|
|
76
|
+
flowCheckouts: { me: (...args: unknown[]) => mockFlowCheckoutsMe(...args) },
|
|
77
|
+
subscription: {
|
|
78
|
+
me: (...args: unknown[]) => mockSubscriptionMe(...args),
|
|
79
|
+
portalSession: jest.fn(),
|
|
80
|
+
},
|
|
81
|
+
leads: { submit: (...args: unknown[]) => mockLeadsSubmit(...args) },
|
|
82
|
+
// #7920 — the member kinds read the hub with the credential; 404 here.
|
|
83
|
+
member: {
|
|
84
|
+
pieces: (...args: unknown[]) => mockMemberRead(...args),
|
|
85
|
+
messages: (...args: unknown[]) => mockMemberRead(...args),
|
|
86
|
+
},
|
|
87
|
+
visitorForwardHeaders: () => null,
|
|
88
|
+
hubSignIn: { me: jest.fn() },
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
jest.mock("better-auth/react", () => ({
|
|
92
|
+
__esModule: true,
|
|
93
|
+
createAuthClient: () => ({ signOut: jest.fn(), updateUser: jest.fn() }),
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
jest.mock("next/navigation", () => ({
|
|
97
|
+
__esModule: true,
|
|
98
|
+
useRouter: () => ({ push: jest.fn(), refresh: jest.fn() }),
|
|
99
|
+
usePathname: () => "/account/settings",
|
|
100
|
+
redirect: jest.fn(),
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
import { seal } from "../../../../../lib/hub-session/seal";
|
|
104
|
+
import { accountSession, loadInboxItems } from "../account-data";
|
|
105
|
+
import { submitSupportRequest } from "../support-actions";
|
|
106
|
+
import Page from "../settings/page";
|
|
107
|
+
|
|
108
|
+
const HUB = "https://hub.example.com";
|
|
109
|
+
const SESSION_SECRET = "s".repeat(43);
|
|
110
|
+
const CREDENTIAL = `ailk_hst_${"t".repeat(43)}`;
|
|
111
|
+
const USER = {
|
|
112
|
+
name: "Ada Lovelace",
|
|
113
|
+
email: "ada@example.com",
|
|
114
|
+
emailVerified: true,
|
|
115
|
+
createdAt: "2026-09-01T00:00:00.000Z",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const ENV = process.env;
|
|
119
|
+
let contentRoot: string;
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
contentRoot = mkdtempSync(join(tmpdir(), "ailk-hub-mode-"));
|
|
123
|
+
writeFileSync(join(contentRoot, "_site.mdx"), '---\nsiteName: "Test"\n---\n');
|
|
124
|
+
process.env = {
|
|
125
|
+
...ENV,
|
|
126
|
+
AILK_CONTENT_ROOT: contentRoot,
|
|
127
|
+
AILK_HUB_SIGN_IN_URL: HUB,
|
|
128
|
+
AILK_HUB_CLIENT_ID: "spoke_0001",
|
|
129
|
+
AILK_HUB_CLIENT_SECRET: `ailk_hcs_${"c".repeat(43)}`,
|
|
130
|
+
AILK_HUB_SESSION_SECRET: SESSION_SECRET,
|
|
131
|
+
NEXT_PUBLIC_BASE_URL: "https://spoke.example.com",
|
|
132
|
+
};
|
|
133
|
+
const sealed = seal(
|
|
134
|
+
{ v: 1, accessToken: CREDENTIAL, expiresAt: Date.now() + 60_000, user: USER },
|
|
135
|
+
SESSION_SECRET,
|
|
136
|
+
"session",
|
|
137
|
+
"__Host-ailk-hub-session",
|
|
138
|
+
);
|
|
139
|
+
mockHeaders.mockReset().mockResolvedValue(new Headers({ cookie: `__Host-ailk-hub-session=${sealed}` }));
|
|
140
|
+
mockReadSession.mockReset().mockResolvedValue({ user: USER });
|
|
141
|
+
mockWaitlistMe.mockReset().mockResolvedValue(notFound);
|
|
142
|
+
mockFlowCheckoutsMe.mockReset().mockResolvedValue(notFound);
|
|
143
|
+
mockSubscriptionMe.mockReset().mockResolvedValue(notFound);
|
|
144
|
+
mockMemberRead.mockReset().mockResolvedValue(notFound);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
afterEach(() => {
|
|
148
|
+
rmSync(contentRoot, { recursive: true, force: true });
|
|
149
|
+
process.env = ENV;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("AC-7 — the base Inbox kinds on a hub-mode spoke", () => {
|
|
153
|
+
it("loads [] for result and purchase, with failed false, when the hub answers 404", async () => {
|
|
154
|
+
const session = await accountSession("en");
|
|
155
|
+
if (session === null) throw new Error("expected a session");
|
|
156
|
+
expect(session.ctx.init).toEqual({ headers: { authorization: `Bearer ${CREDENTIAL}` } });
|
|
157
|
+
|
|
158
|
+
const inbox = await loadInboxItems(session.ctx);
|
|
159
|
+
expect(inbox).toEqual({ items: [], failed: false });
|
|
160
|
+
expect(mockFlowCheckoutsMe).toHaveBeenCalledWith({
|
|
161
|
+
headers: { authorization: `Bearer ${CREDENTIAL}` },
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("has no session when the seam reads none", async () => {
|
|
166
|
+
mockReadSession.mockResolvedValue(null);
|
|
167
|
+
await expect(accountSession("en")).resolves.toBeNull();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("AC-5 — the settings page on a hub-mode spoke", () => {
|
|
172
|
+
async function renderPage(): Promise<void> {
|
|
173
|
+
render(await Page({ params: Promise.resolve({ locale: "en" }) }));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
it("renders no NameForm and no deletion request, and links to the hub's account settings", async () => {
|
|
177
|
+
await renderPage();
|
|
178
|
+
expect(screen.queryByTestId("account-name-form")).not.toBeInTheDocument();
|
|
179
|
+
expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
|
|
180
|
+
const href = `${HUB}/account/settings`;
|
|
181
|
+
expect(screen.getByTestId("account-settings-manage-on-hub")).toHaveAttribute("href", href);
|
|
182
|
+
expect(screen.getByTestId("account-privacy-manage-on-hub")).toHaveAttribute("href", href);
|
|
183
|
+
expect(screen.getByTestId("account-settings-name")).toHaveTextContent("Ada Lovelace");
|
|
184
|
+
expect(screen.getByText(/You sign in through our main site/)).toBeInTheDocument();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("files a posted deletion request as a support request — the hub owns the account (SEC-7894-11)", async () => {
|
|
188
|
+
mockLeadsSubmit.mockResolvedValue({ ok: true, body: {} });
|
|
189
|
+
const form = new FormData();
|
|
190
|
+
form.set("kind", "deletion");
|
|
191
|
+
form.set("subject", "Delete me");
|
|
192
|
+
form.set("message", "Please delete my account.");
|
|
193
|
+
await expect(submitSupportRequest({ kind: "idle" }, form)).resolves.toEqual({ kind: "success" });
|
|
194
|
+
expect(mockLeadsSubmit).toHaveBeenCalledWith(
|
|
195
|
+
expect.objectContaining({ email: USER.email, payload: expect.objectContaining({ kind: "support" }) }),
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("omits Billing, whose read answers 404 for the hub credential", async () => {
|
|
200
|
+
await renderPage();
|
|
201
|
+
expect(document.getElementById("billing")).not.toBeInTheDocument();
|
|
202
|
+
expect(mockSubscriptionMe).toHaveBeenCalledWith({
|
|
203
|
+
headers: { authorization: `Bearer ${CREDENTIAL}` },
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
* only: no For-you stack, no bottom sheet, no large title. The For you
|
|
18
18
|
* tab lists every For-you item in one inset card, and ✕ dismisses one.
|
|
19
19
|
* At `md`+ only the Inbox panel shows (the aside holds For you).
|
|
20
|
+
* 7. #7920 — the member area's `piece` and `message` kinds and the
|
|
21
|
+
* needs-client For-you source. Each reads the hub with `ctx.init`
|
|
22
|
+
* unchanged (the caller's own credential); 404 and an unverified 401/403
|
|
23
|
+
* load `[]`, any other failure rejects (D12). With no hub credential on
|
|
24
|
+
* `ctx.init` — a cookie-session site — they make no call and load `[]`.
|
|
20
25
|
*/
|
|
21
26
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
22
27
|
|
|
@@ -64,11 +69,17 @@ jest.mock("../../../../../auth", () => ({
|
|
|
64
69
|
const mockSignupMe = jest.fn();
|
|
65
70
|
const mockCheckoutsMe = jest.fn();
|
|
66
71
|
const mockSubscriptionMe = jest.fn();
|
|
72
|
+
const mockMemberPieces = jest.fn();
|
|
73
|
+
const mockMemberMessages = jest.fn();
|
|
67
74
|
jest.mock("@working-theory/api-client", () => ({
|
|
68
75
|
__esModule: true,
|
|
69
76
|
waitlistSignups: { me: (...args: unknown[]) => mockSignupMe(...args) },
|
|
70
77
|
flowCheckouts: { me: (...args: unknown[]) => mockCheckoutsMe(...args) },
|
|
71
78
|
subscription: { me: (...args: unknown[]) => mockSubscriptionMe(...args) },
|
|
79
|
+
member: {
|
|
80
|
+
pieces: (...args: unknown[]) => mockMemberPieces(...args),
|
|
81
|
+
messages: (...args: unknown[]) => mockMemberMessages(...args),
|
|
82
|
+
},
|
|
72
83
|
}));
|
|
73
84
|
|
|
74
85
|
const mockRefresh = jest.fn();
|
|
@@ -78,9 +89,18 @@ jest.mock("next/navigation", () => ({
|
|
|
78
89
|
usePathname: () => "/account",
|
|
79
90
|
}));
|
|
80
91
|
|
|
81
|
-
import {
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
import {
|
|
93
|
+
PhoneTabProvider,
|
|
94
|
+
type AccountContext,
|
|
95
|
+
type AccountTranslate,
|
|
96
|
+
} from "@working-theory/templates/account";
|
|
97
|
+
import { getTranslations } from "next-intl/server";
|
|
98
|
+
|
|
99
|
+
import {
|
|
100
|
+
accountExperimentKeys,
|
|
101
|
+
accountForYouSources,
|
|
102
|
+
accountInboxKinds,
|
|
103
|
+
} from "../../../../../lib/account-config";
|
|
84
104
|
import Page from "../page";
|
|
85
105
|
|
|
86
106
|
const SESSION = {
|
|
@@ -121,6 +141,80 @@ const NO_SUBSCRIPTION = {
|
|
|
121
141
|
body: { actionRequired: false, status: null, accessRevokesAt: null, portalAvailable: false },
|
|
122
142
|
};
|
|
123
143
|
|
|
144
|
+
const PIECE = {
|
|
145
|
+
id: "piece_01920000-0000-7000-8000-000000000001",
|
|
146
|
+
title: "Spring newsletter",
|
|
147
|
+
stage: "Drafting",
|
|
148
|
+
needsClient: false,
|
|
149
|
+
createdAt: "2026-09-05T09:00:00.000Z",
|
|
150
|
+
updatedAt: "2026-09-06T09:00:00.000Z",
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const PIECE_NEEDS_CLIENT = {
|
|
154
|
+
id: "piece_01920000-0000-7000-8000-000000000002",
|
|
155
|
+
title: "About page",
|
|
156
|
+
stage: "Review",
|
|
157
|
+
needsClient: true,
|
|
158
|
+
createdAt: "2026-09-02T09:00:00.000Z",
|
|
159
|
+
updatedAt: "2026-09-07T09:00:00.000Z",
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const MESSAGE = {
|
|
163
|
+
id: "intx_01920000-0000-7000-8000-000000000003",
|
|
164
|
+
subject: "Kickoff notes",
|
|
165
|
+
body: "Thanks for the call.",
|
|
166
|
+
file: null,
|
|
167
|
+
createdAt: "2026-09-08T09:00:00.000Z",
|
|
168
|
+
readAt: null,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const MESSAGE_WITH_FILE = {
|
|
172
|
+
id: "intx_01920000-0000-7000-8000-000000000004",
|
|
173
|
+
subject: "Your brand guide",
|
|
174
|
+
body: "Attached.",
|
|
175
|
+
file: { filename: "brand-guide.pdf", contentType: "application/pdf" },
|
|
176
|
+
createdAt: "2026-09-09T09:00:00.000Z",
|
|
177
|
+
readAt: "2026-09-09T10:00:00.000Z",
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** The hub credential a spoke signed in through its hub forwards (#7894 D12). */
|
|
181
|
+
const BEARER = { headers: { authorization: `Bearer ailk_hst_${"t".repeat(43)}` } };
|
|
182
|
+
|
|
183
|
+
const notFound = { ok: false, status: 404, body: { ok: false, error: { code: "not_found", message: "Not found" } } };
|
|
184
|
+
|
|
185
|
+
/** The context a hub-mode spoke hands every contribution, signed in. */
|
|
186
|
+
async function memberCtx(
|
|
187
|
+
overrides: Partial<AccountContext> = {},
|
|
188
|
+
): Promise<AccountContext> {
|
|
189
|
+
const locale = overrides.locale ?? "en";
|
|
190
|
+
// The contract's translator takes any key (account-data.ts's reasoning):
|
|
191
|
+
// forward through the mock's runtime signature.
|
|
192
|
+
const translate = (await getTranslations({ locale, namespace: "account" })) as (
|
|
193
|
+
key: string,
|
|
194
|
+
values?: Record<string, unknown>,
|
|
195
|
+
) => string;
|
|
196
|
+
const t: AccountTranslate = (key, values) => translate(key, values);
|
|
197
|
+
return {
|
|
198
|
+
locale,
|
|
199
|
+
user: { name: "Ada", email: "ada@example.com", emailVerified: true },
|
|
200
|
+
t,
|
|
201
|
+
init: BEARER,
|
|
202
|
+
...overrides,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function kindOf(id: string): (typeof accountInboxKinds)[number] {
|
|
207
|
+
const kind = accountInboxKinds.find((k) => k.kind === id);
|
|
208
|
+
if (!kind) throw new Error(`no ${id} kind`);
|
|
209
|
+
return kind;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function needsClientSource(): (typeof accountForYouSources)[number] {
|
|
213
|
+
const source = accountForYouSources.find((s) => s.id === "piece-needs-client");
|
|
214
|
+
if (!source) throw new Error("no piece-needs-client source");
|
|
215
|
+
return source;
|
|
216
|
+
}
|
|
217
|
+
|
|
124
218
|
/** Renders the page on a phone tab — the frame's PhoneTabProvider. */
|
|
125
219
|
async function renderPage(locale = "en", tab = "inbox"): Promise<void> {
|
|
126
220
|
const element = await Page({ params: Promise.resolve({ locale }) });
|
|
@@ -144,6 +238,8 @@ beforeEach(() => {
|
|
|
144
238
|
mockSignupMe.mockReset().mockResolvedValue({ ok: true, body: SIGNUP });
|
|
145
239
|
mockCheckoutsMe.mockReset().mockResolvedValue({ ok: true, body: [CHECKOUT] });
|
|
146
240
|
mockSubscriptionMe.mockReset().mockResolvedValue(NO_SUBSCRIPTION);
|
|
241
|
+
mockMemberPieces.mockReset().mockResolvedValue({ ok: true, body: { items: [PIECE, PIECE_NEEDS_CLIENT] } });
|
|
242
|
+
mockMemberMessages.mockReset().mockResolvedValue({ ok: true, body: { items: [MESSAGE, MESSAGE_WITH_FILE] } });
|
|
147
243
|
mockRefresh.mockReset();
|
|
148
244
|
});
|
|
149
245
|
|
|
@@ -375,3 +471,160 @@ describe("Hub unavailable (D12)", () => {
|
|
|
375
471
|
expect(within(panel).queryByTestId("inbox-error")).not.toBeInTheDocument();
|
|
376
472
|
});
|
|
377
473
|
});
|
|
474
|
+
|
|
475
|
+
describe("the member kinds — piece and message (#7920, spec 7423 D8 · D12)", () => {
|
|
476
|
+
it("declares piece and message as Inbox kinds in the updates section, labelled from the account namespace", async () => {
|
|
477
|
+
const ctx = await memberCtx();
|
|
478
|
+
expect(kindOf("piece").section).toBe("updates");
|
|
479
|
+
expect(kindOf("message").section).toBe("updates");
|
|
480
|
+
expect(ctx.t(kindOf("piece").label)).toBe("My pieces");
|
|
481
|
+
expect(ctx.t(kindOf("message").label)).toBe("My messages");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("each kind reads the hub with ctx.init unchanged — the caller's own credential", async () => {
|
|
485
|
+
const ctx = await memberCtx();
|
|
486
|
+
const pieces = await kindOf("piece").load(ctx);
|
|
487
|
+
const messages = await kindOf("message").load(ctx);
|
|
488
|
+
expect(mockMemberPieces).toHaveBeenCalledWith(BEARER);
|
|
489
|
+
expect(mockMemberMessages).toHaveBeenCalledWith(BEARER);
|
|
490
|
+
expect(pieces.map((p) => [p.id, p.title, p.at])).toEqual([
|
|
491
|
+
[PIECE.id, "Spring newsletter", PIECE.updatedAt],
|
|
492
|
+
[PIECE_NEEDS_CLIENT.id, "About page", PIECE_NEEDS_CLIENT.updatedAt],
|
|
493
|
+
]);
|
|
494
|
+
expect(messages.map((m) => [m.id, m.title, m.at])).toEqual([
|
|
495
|
+
[MESSAGE.id, "Kickoff notes", MESSAGE.createdAt],
|
|
496
|
+
[MESSAGE_WITH_FILE.id, "Your brand guide", MESSAGE_WITH_FILE.createdAt],
|
|
497
|
+
]);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it.each(["piece", "message"])("%s loads [] on a 404 — nothing for this caller", async (id) => {
|
|
501
|
+
mockMemberPieces.mockResolvedValue(notFound);
|
|
502
|
+
mockMemberMessages.mockResolvedValue(notFound);
|
|
503
|
+
await expect(kindOf(id).load(await memberCtx())).resolves.toEqual([]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it.each([
|
|
507
|
+
["piece", 401],
|
|
508
|
+
["piece", 403],
|
|
509
|
+
["message", 401],
|
|
510
|
+
["message", 403],
|
|
511
|
+
])("%s loads [] on a %i while the email is unverified", async (id, status) => {
|
|
512
|
+
const refusal = { ok: false, status, body: { ok: false, error: { code: "email_unverified" } } };
|
|
513
|
+
mockMemberPieces.mockResolvedValue(refusal);
|
|
514
|
+
mockMemberMessages.mockResolvedValue(refusal);
|
|
515
|
+
const ctx = await memberCtx({ user: { name: "Ada", email: "ada@example.com", emailVerified: false } });
|
|
516
|
+
await expect(kindOf(id).load(ctx)).resolves.toEqual([]);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it.each([
|
|
520
|
+
["piece", 401],
|
|
521
|
+
["piece", 403],
|
|
522
|
+
["piece", 500],
|
|
523
|
+
["piece", 0],
|
|
524
|
+
["message", 401],
|
|
525
|
+
["message", 403],
|
|
526
|
+
["message", 502],
|
|
527
|
+
["message", 0],
|
|
528
|
+
])("%s rejects on a %i for a verified email — never a partial list shown as whole", async (id, status) => {
|
|
529
|
+
const failure = {
|
|
530
|
+
ok: false,
|
|
531
|
+
status,
|
|
532
|
+
body: { ok: false, error: { code: status === 0 ? "insecure_transport" : "failed", message: "x" } },
|
|
533
|
+
};
|
|
534
|
+
mockMemberPieces.mockResolvedValue(failure);
|
|
535
|
+
mockMemberMessages.mockResolvedValue(failure);
|
|
536
|
+
await expect(kindOf(id).load(await memberCtx())).rejects.toMatchObject({ status });
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("with no hub credential on ctx.init (a cookie-session site) both kinds and the source load [] without a call", async () => {
|
|
540
|
+
const ctx = await memberCtx({ init: { headers: { cookie: "session=abc" } } });
|
|
541
|
+
await expect(kindOf("piece").load(ctx)).resolves.toEqual([]);
|
|
542
|
+
await expect(kindOf("message").load(ctx)).resolves.toEqual([]);
|
|
543
|
+
await expect(needsClientSource().load(ctx)).resolves.toEqual([]);
|
|
544
|
+
expect(mockMemberPieces).not.toHaveBeenCalled();
|
|
545
|
+
expect(mockMemberMessages).not.toHaveBeenCalled();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("the cookie-session Inbox stays as it was: no member rows, no error row, no member call", async () => {
|
|
549
|
+
await renderPage();
|
|
550
|
+
expect(screen.queryByTestId("inbox-error")).not.toBeInTheDocument();
|
|
551
|
+
expect(screen.queryByText("Spring newsletter")).not.toBeInTheDocument();
|
|
552
|
+
expect(screen.queryByText("Kickoff notes")).not.toBeInTheDocument();
|
|
553
|
+
expect(mockMemberPieces).not.toHaveBeenCalled();
|
|
554
|
+
expect(mockMemberMessages).not.toHaveBeenCalled();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("a piece's detail shows its stage and dates", async () => {
|
|
558
|
+
const ctx = await memberCtx();
|
|
559
|
+
const [first] = await kindOf("piece").load(ctx);
|
|
560
|
+
const detail = kindOf("piece").renderDetail(first!, ctx);
|
|
561
|
+
expect(detail.status).toBe("Drafting");
|
|
562
|
+
expect(detail.fields?.heading).toBe("My pieces");
|
|
563
|
+
expect(detail.fields?.items.map((i) => i.label)).toEqual(["Stage", "Started", "Updated"]);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("a message with an asset shows a file action that opens the member file read", async () => {
|
|
567
|
+
const ctx = await memberCtx();
|
|
568
|
+
const items = await kindOf("message").load(ctx);
|
|
569
|
+
const withFile = items.find((m) => m.id === MESSAGE_WITH_FILE.id)!;
|
|
570
|
+
const detail = kindOf("message").renderDetail(withFile, ctx);
|
|
571
|
+
expect(detail.actions).toEqual([
|
|
572
|
+
{
|
|
573
|
+
key: "file",
|
|
574
|
+
label: "Download brand-guide.pdf",
|
|
575
|
+
variant: "default",
|
|
576
|
+
href: `/account/inbox/message/${MESSAGE_WITH_FILE.id}/file`,
|
|
577
|
+
},
|
|
578
|
+
]);
|
|
579
|
+
expect(detail.fields?.items).toContainEqual({ label: "File", value: "brand-guide.pdf" });
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("the file action carries the locale prefix off the default locale", async () => {
|
|
583
|
+
const ctx = await memberCtx({ locale: "fr" });
|
|
584
|
+
const items = await kindOf("message").load(ctx);
|
|
585
|
+
const withFile = items.find((m) => m.id === MESSAGE_WITH_FILE.id)!;
|
|
586
|
+
expect(kindOf("message").renderDetail(withFile, ctx).actions?.[0]?.href).toBe(
|
|
587
|
+
`/fr/account/inbox/message/${MESSAGE_WITH_FILE.id}/file`,
|
|
588
|
+
);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("a message without an asset has no file action", async () => {
|
|
592
|
+
const ctx = await memberCtx();
|
|
593
|
+
const [plain] = await kindOf("message").load(ctx);
|
|
594
|
+
const detail = kindOf("message").renderDetail(plain!, ctx);
|
|
595
|
+
expect(detail.actions).toBeUndefined();
|
|
596
|
+
expect(detail.fields?.items.map((i) => i.label)).not.toContain("File");
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
describe("the needs-client For-you source (#7920)", () => {
|
|
601
|
+
it("a needsClient piece is a needs-you For-you item with an Open action to its Inbox row", async () => {
|
|
602
|
+
const items = await needsClientSource().load(await memberCtx());
|
|
603
|
+
expect(items).toEqual([
|
|
604
|
+
{
|
|
605
|
+
id: `piece:${PIECE_NEEDS_CLIENT.id}`,
|
|
606
|
+
type: { id: "piece", label: "Piece", tone: "needs-you" },
|
|
607
|
+
title: "About page",
|
|
608
|
+
time: { kind: "at", at: PIECE_NEEDS_CLIENT.updatedAt },
|
|
609
|
+
action: { kind: "open", href: `/account/inbox/piece/${PIECE_NEEDS_CLIENT.id}` },
|
|
610
|
+
dismissible: true,
|
|
611
|
+
},
|
|
612
|
+
]);
|
|
613
|
+
expect(mockMemberPieces).toHaveBeenCalledWith(BEARER);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it("off the default locale the Open href carries the prefix", async () => {
|
|
617
|
+
const [item] = await needsClientSource().load(await memberCtx({ locale: "fr" }));
|
|
618
|
+
expect(item?.action).toEqual({
|
|
619
|
+
kind: "open",
|
|
620
|
+
href: `/fr/account/inbox/piece/${PIECE_NEEDS_CLIENT.id}`,
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("loads [] on a 404 and rejects on any other failure", async () => {
|
|
625
|
+
mockMemberPieces.mockResolvedValue(notFound);
|
|
626
|
+
await expect(needsClientSource().load(await memberCtx())).resolves.toEqual([]);
|
|
627
|
+
mockMemberPieces.mockResolvedValue({ ok: false, status: 500, body: {} });
|
|
628
|
+
await expect(needsClientSource().load(await memberCtx())).rejects.toMatchObject({ status: 500 });
|
|
629
|
+
});
|
|
630
|
+
});
|
|
@@ -34,9 +34,9 @@ import { headers } from "next/headers";
|
|
|
34
34
|
import { getTranslations } from "next-intl/server";
|
|
35
35
|
import { cache, createElement } from "react";
|
|
36
36
|
|
|
37
|
-
import { auth } from "../../../../auth";
|
|
38
37
|
import { accountForYouSources, accountInboxKinds } from "../../../../lib/account-config";
|
|
39
38
|
import { forwardedCookieInit } from "../../../../lib/server-api";
|
|
39
|
+
import { readSession } from "../../../../lib/topology.generated";
|
|
40
40
|
|
|
41
41
|
/** The session's person, plus what the frame shows beyond the contract. */
|
|
42
42
|
export interface AccountSession {
|
|
@@ -48,18 +48,15 @@ export interface AccountSession {
|
|
|
48
48
|
/** The `AccountContext` for this request, or `null` when there is no session. */
|
|
49
49
|
export const accountSession = cache(
|
|
50
50
|
async (locale: string): Promise<AccountSession | null> => {
|
|
51
|
-
|
|
51
|
+
// Through the generated seam (#7894 D10): Better Auth on a hub, the sealed
|
|
52
|
+
// hub-session cookie on a spoke that signs in through its hub.
|
|
53
|
+
const session = await readSession(await headers());
|
|
52
54
|
if (!session) return null;
|
|
53
55
|
// The contract's translator takes any key: a contribution's keys (a
|
|
54
56
|
// kind's `label`, a section's `title`) are data, which next-intl's
|
|
55
57
|
// literal-key typing cannot see. The runtime call is the same function.
|
|
56
58
|
const t = (await getTranslations({ locale, namespace: "account" })) as unknown as AccountTranslate;
|
|
57
|
-
const { name, email, emailVerified, createdAt } = session.user
|
|
58
|
-
name?: string | null;
|
|
59
|
-
email: string;
|
|
60
|
-
emailVerified: boolean;
|
|
61
|
-
createdAt?: Date | string | null;
|
|
62
|
-
};
|
|
59
|
+
const { name, email, emailVerified, createdAt } = session.user;
|
|
63
60
|
return {
|
|
64
61
|
ctx: {
|
|
65
62
|
locale,
|