create-ailk 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -0
- package/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +80 -3
- package/dist/add-route.js +26 -2
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +103 -3
- package/dist/derive-architecture.d.ts +37 -2
- package/dist/derive-architecture.js +92 -7
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +20 -0
- package/dist/lib/extract-module-bundle.js +53 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +82 -0
- package/dist/parse-args.d.ts +27 -0
- package/dist/parse-args.js +89 -0
- package/dist/programmatic.d.ts +28 -0
- package/dist/programmatic.js +181 -11
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +55 -5
- package/dist/site-manifest.js +121 -12
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +42 -0
- package/dist/spoke-checkout.js +359 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +29 -5
- package/dist/topology.d.ts +246 -0
- package/dist/topology.js +942 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +21 -4
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +41 -3
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +129 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +278 -30
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/CLAUDE.md +1 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +83 -18
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
- package/templates/apps/web/app/openapi.json/route.ts +72 -7
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +209 -3
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/agents.db.test.ts +85 -0
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +24 -0
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +111 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -34
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The member file read, re-served by the spoke (#7920; SEC-7877-D) —
|
|
7
|
+
* `GET /[locale]/account/inbox/message/:id/file`.
|
|
8
|
+
*
|
|
9
|
+
* A message's file action links here: the browser holds neither the hub
|
|
10
|
+
* credential (sealed in the `hub-session` cookie) nor the spoke key, so the
|
|
11
|
+
* spoke's server reads `GET /v1/member/messages/:id/file` with
|
|
12
|
+
* `forwardedCookieInit()` and streams the bytes back. The api-client's
|
|
13
|
+
* `member.messageFile` is the one external boundary mocked; the seal, the
|
|
14
|
+
* cookie read and the route's decisions are real. Covers:
|
|
15
|
+
* - a 404 unless the web seam's `SIGN_IN_PROVIDER` is `"hub"`, no hub call;
|
|
16
|
+
* - the read carries the signed-in caller's own credential, and the bytes
|
|
17
|
+
* come back with all five hub headers unchanged;
|
|
18
|
+
* - a hub answer missing any of the five is refused 502 — never bytes on
|
|
19
|
+
* this origin without them;
|
|
20
|
+
* - refusals map to a status with no body and `private, no-store`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const mockMessageFile = jest.fn();
|
|
24
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
25
|
+
__esModule: true,
|
|
26
|
+
member: { messageFile: (...args: unknown[]) => mockMessageFile(...args) },
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const mockProvider: { value: "site" | "hub" | "none" } = { value: "hub" };
|
|
30
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the sign-in provider is a generated constant (#7894 D8), not a runtime input; the seam IS the boundary between a hub and a hub-mode spoke build, and no env or HTTP surface selects it.
|
|
31
|
+
jest.mock("../../../../../../../../../lib/topology.generated", () => ({
|
|
32
|
+
__esModule: true,
|
|
33
|
+
get SIGN_IN_PROVIDER() {
|
|
34
|
+
return mockProvider.value;
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const mockHeaders = jest.fn();
|
|
39
|
+
jest.mock("next/headers", () => ({
|
|
40
|
+
__esModule: true,
|
|
41
|
+
headers: (...args: unknown[]) => mockHeaders(...args),
|
|
42
|
+
cookies: jest.fn().mockResolvedValue({ toString: () => "" }),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
import { seal } from "../../../../../../../../../lib/hub-session/seal";
|
|
46
|
+
import { GET } from "../route";
|
|
47
|
+
|
|
48
|
+
const ENV = process.env;
|
|
49
|
+
const SITE = "https://spoke.example.com";
|
|
50
|
+
const SESSION_SECRET = "s".repeat(43);
|
|
51
|
+
const COOKIE = "__Host-ailk-hub-session";
|
|
52
|
+
const CREDENTIAL = `ailk_hst_${"t".repeat(43)}`;
|
|
53
|
+
const ID = "intx_01920000-0000-7000-8000-000000000004";
|
|
54
|
+
|
|
55
|
+
const FILE_HEADERS = {
|
|
56
|
+
contentType: "application/pdf",
|
|
57
|
+
contentDisposition: 'attachment; filename="brand-guide.pdf"',
|
|
58
|
+
contentTypeOptions: "nosniff",
|
|
59
|
+
contentSecurityPolicy: "default-src 'none'; sandbox",
|
|
60
|
+
cacheControl: "private, no-store",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function fileResult(headers: Partial<Record<keyof typeof FILE_HEADERS, string | null>> = {}) {
|
|
64
|
+
return {
|
|
65
|
+
ok: true,
|
|
66
|
+
body: {
|
|
67
|
+
stream: new Response("%PDF-1.7 bytes").body,
|
|
68
|
+
headers: { ...FILE_HEADERS, ...headers },
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function signedIn(): void {
|
|
74
|
+
const sealed = seal(
|
|
75
|
+
{
|
|
76
|
+
v: 1,
|
|
77
|
+
accessToken: CREDENTIAL,
|
|
78
|
+
expiresAt: Date.now() + 60_000,
|
|
79
|
+
user: { name: "Ada", email: "ada@example.com", emailVerified: true, createdAt: "2026-09-01T00:00:00.000Z" },
|
|
80
|
+
},
|
|
81
|
+
SESSION_SECRET,
|
|
82
|
+
"session",
|
|
83
|
+
COOKIE,
|
|
84
|
+
);
|
|
85
|
+
mockHeaders.mockResolvedValue(new Headers({ cookie: `${COOKIE}=${sealed}` }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function get(id = ID): Promise<Response> {
|
|
89
|
+
return GET(new Request(`${SITE}/account/inbox/message/${id}/file`), {
|
|
90
|
+
params: Promise.resolve({ locale: "en", id }),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
process.env = {
|
|
96
|
+
...ENV,
|
|
97
|
+
AILK_HUB_SIGN_IN_URL: "https://hub.example.com",
|
|
98
|
+
AILK_HUB_CLIENT_ID: "spoke_0001",
|
|
99
|
+
AILK_HUB_CLIENT_SECRET: `ailk_hcs_${"c".repeat(43)}`,
|
|
100
|
+
AILK_HUB_SESSION_SECRET: SESSION_SECRET,
|
|
101
|
+
NEXT_PUBLIC_BASE_URL: SITE,
|
|
102
|
+
};
|
|
103
|
+
mockProvider.value = "hub";
|
|
104
|
+
mockHeaders.mockReset().mockResolvedValue(new Headers());
|
|
105
|
+
mockMessageFile.mockReset().mockResolvedValue(fileResult());
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
afterAll(() => {
|
|
109
|
+
process.env = ENV;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("the member file read on a spoke that signs in through its hub", () => {
|
|
113
|
+
it("reads the file with the caller's own credential and re-serves it with all five headers unchanged", async () => {
|
|
114
|
+
signedIn();
|
|
115
|
+
const res = await get();
|
|
116
|
+
expect(mockMessageFile).toHaveBeenCalledWith(ID, {
|
|
117
|
+
headers: { authorization: `Bearer ${CREDENTIAL}` },
|
|
118
|
+
});
|
|
119
|
+
expect(res.status).toBe(200);
|
|
120
|
+
expect(res.headers.get("content-type")).toBe(FILE_HEADERS.contentType);
|
|
121
|
+
expect(res.headers.get("content-disposition")).toBe(FILE_HEADERS.contentDisposition);
|
|
122
|
+
expect(res.headers.get("x-content-type-options")).toBe(FILE_HEADERS.contentTypeOptions);
|
|
123
|
+
expect(res.headers.get("content-security-policy")).toBe(FILE_HEADERS.contentSecurityPolicy);
|
|
124
|
+
expect(res.headers.get("cache-control")).toBe(FILE_HEADERS.cacheControl);
|
|
125
|
+
await expect(res.text()).resolves.toBe("%PDF-1.7 bytes");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it.each(Object.keys(FILE_HEADERS) as (keyof typeof FILE_HEADERS)[])(
|
|
129
|
+
"refuses 502 with no bytes when the hub's answer lacks %s",
|
|
130
|
+
async (name) => {
|
|
131
|
+
signedIn();
|
|
132
|
+
const result = fileResult({ [name]: null });
|
|
133
|
+
const cancel = jest.spyOn(result.body.stream as ReadableStream, "cancel");
|
|
134
|
+
mockMessageFile.mockResolvedValue(result);
|
|
135
|
+
const refused = await get();
|
|
136
|
+
expect(refused.status).toBe(502);
|
|
137
|
+
expect(cancel).toHaveBeenCalled();
|
|
138
|
+
expect(refused.headers.get("cache-control")).toBe("private, no-store");
|
|
139
|
+
expect(refused.headers.get("content-disposition")).toBeNull();
|
|
140
|
+
await expect(refused.text()).resolves.toBe("");
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
it("signed out, the read goes with no credential and its refusal answers 401", async () => {
|
|
145
|
+
mockMessageFile.mockResolvedValue({
|
|
146
|
+
ok: false,
|
|
147
|
+
status: 0,
|
|
148
|
+
body: { ok: false, error: { code: "unauthorized", message: "No usable credential" } },
|
|
149
|
+
});
|
|
150
|
+
const res = await get();
|
|
151
|
+
expect(mockMessageFile).toHaveBeenCalledWith(ID, { headers: {} });
|
|
152
|
+
expect(res.status).toBe(401);
|
|
153
|
+
expect(res.headers.get("cache-control")).toBe("private, no-store");
|
|
154
|
+
await expect(res.text()).resolves.toBe("");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it.each([
|
|
158
|
+
[404, 404],
|
|
159
|
+
[401, 401],
|
|
160
|
+
[403, 403],
|
|
161
|
+
[429, 429],
|
|
162
|
+
[500, 502],
|
|
163
|
+
[503, 502],
|
|
164
|
+
])("a hub %i answers %i, with no body", async (hubStatus, status) => {
|
|
165
|
+
signedIn();
|
|
166
|
+
mockMessageFile.mockResolvedValue({
|
|
167
|
+
ok: false,
|
|
168
|
+
status: hubStatus,
|
|
169
|
+
body: { ok: false, error: { code: "x", message: "x" } },
|
|
170
|
+
});
|
|
171
|
+
const res = await get();
|
|
172
|
+
expect(res.status).toBe(status);
|
|
173
|
+
expect(res.headers.get("cache-control")).toBe("private, no-store");
|
|
174
|
+
await expect(res.text()).resolves.toBe("");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("an API base the api-client refuses (insecure_transport) answers 502", async () => {
|
|
178
|
+
signedIn();
|
|
179
|
+
mockMessageFile.mockResolvedValue({
|
|
180
|
+
ok: false,
|
|
181
|
+
status: 0,
|
|
182
|
+
body: { ok: false, error: { code: "insecure_transport", message: "x" } },
|
|
183
|
+
});
|
|
184
|
+
expect((await get()).status).toBe(502);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("a hub call that throws (network failure, a redirect under redirect: error) answers 502 with no body", async () => {
|
|
188
|
+
signedIn();
|
|
189
|
+
mockMessageFile.mockRejectedValue(new TypeError("fetch failed"));
|
|
190
|
+
const res = await get();
|
|
191
|
+
expect(res.status).toBe(502);
|
|
192
|
+
expect(res.headers.get("cache-control")).toBe("private, no-store");
|
|
193
|
+
await expect(res.text()).resolves.toBe("");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("on a site that does not sign in through a hub", () => {
|
|
198
|
+
it.each(["site", "none"] as const)("SIGN_IN_PROVIDER %s answers 404 and calls nothing", async (provider) => {
|
|
199
|
+
mockProvider.value = provider;
|
|
200
|
+
signedIn();
|
|
201
|
+
const res = await get();
|
|
202
|
+
expect(res.status).toBe(404);
|
|
203
|
+
expect(mockMessageFile).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The member file read, re-served (#7920) — `GET /[locale]/account/inbox/message/:id/file`.
|
|
3
|
+
*
|
|
4
|
+
* A message's file action (the `message` Inbox kind, `lib/account-config.ts`)
|
|
5
|
+
* links here. The browser holds neither the client's hub credential (sealed in
|
|
6
|
+
* the `hub-session` cookie) nor this site's spoke key, so this route reads the
|
|
7
|
+
* hub's `GET /v1/member/messages/:id/file` with `forwardedCookieInit()` — the
|
|
8
|
+
* caller's own credential, so a client only ever gets their own file — and
|
|
9
|
+
* streams the bytes back unread.
|
|
10
|
+
*
|
|
11
|
+
* SEC-7877-D (the obligation in `@working-theory/api-client`'s `member.ts`):
|
|
12
|
+
* the bytes go out with all five hub headers UNCHANGED — `Content-Type`,
|
|
13
|
+
* `Content-Disposition` (attachment), `X-Content-Type-Options: nosniff`, the
|
|
14
|
+
* sandbox `Content-Security-Policy` and `Cache-Control: private, no-store` —
|
|
15
|
+
* so the file is never a document on this origin and never cached on the way.
|
|
16
|
+
* A hub answer missing any of the five is refused 502 rather than served
|
|
17
|
+
* without it.
|
|
18
|
+
*
|
|
19
|
+
* Only a spoke that signs in through its hub (`SIGN_IN_PROVIDER === "hub"`,
|
|
20
|
+
* #7894) carries the credential; anywhere else this is a 404 with no call.
|
|
21
|
+
* A refusal has no body: no credential (signed out) is a 401, a hub 4xx keeps
|
|
22
|
+
* its status, and a hub 5xx, a transport the api-client refuses or a hub call
|
|
23
|
+
* that throws is a 502.
|
|
24
|
+
*/
|
|
25
|
+
import { member, type MemberFileHeaders } from "@working-theory/api-client";
|
|
26
|
+
|
|
27
|
+
import { forwardedCookieInit } from "../../../../../../../../lib/server-api";
|
|
28
|
+
import { SIGN_IN_PROVIDER } from "../../../../../../../../lib/topology.generated";
|
|
29
|
+
|
|
30
|
+
interface Context {
|
|
31
|
+
params: Promise<{ locale: string; id: string }>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The response header each of the five hub headers goes out under. */
|
|
35
|
+
const FILE_HEADER_NAMES: Record<keyof MemberFileHeaders, string> = {
|
|
36
|
+
contentType: "Content-Type",
|
|
37
|
+
contentDisposition: "Content-Disposition",
|
|
38
|
+
contentTypeOptions: "X-Content-Type-Options",
|
|
39
|
+
contentSecurityPolicy: "Content-Security-Policy",
|
|
40
|
+
cacheControl: "Cache-Control",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function refusal(status: number): Response {
|
|
44
|
+
return new Response(null, { status, headers: { "Cache-Control": "private, no-store" } });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The refusal's status, from the api-client's `{ ok: false, status, body }`. */
|
|
48
|
+
function refusalStatus(status: number, body: unknown): number {
|
|
49
|
+
if (status === 0) {
|
|
50
|
+
const code = (body as { error?: { code?: unknown } } | null)?.error?.code;
|
|
51
|
+
return code === "unauthorized" ? 401 : 502;
|
|
52
|
+
}
|
|
53
|
+
return status >= 500 ? 502 : status;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// platform-decisions-waiver: api-versioning — a page-scoped download link on the site's own account area, not an API contract; it re-serves the versioned hub route /v1/member/messages/:id/file
|
|
57
|
+
export async function GET(_request: Request, { params }: Context): Promise<Response> {
|
|
58
|
+
if (SIGN_IN_PROVIDER !== "hub") return refusal(404);
|
|
59
|
+
const { id } = await params;
|
|
60
|
+
|
|
61
|
+
// The api-client throws, rather than answering `{ ok: false }`, on a
|
|
62
|
+
// network failure or a redirect — that is a 502 too, never Next's own 500.
|
|
63
|
+
let res: Awaited<ReturnType<typeof member.messageFile>>;
|
|
64
|
+
try {
|
|
65
|
+
res = await member.messageFile(id, await forwardedCookieInit());
|
|
66
|
+
} catch {
|
|
67
|
+
return refusal(502);
|
|
68
|
+
}
|
|
69
|
+
if (!res.ok) return refusal(refusalStatus(res.status, res.body));
|
|
70
|
+
|
|
71
|
+
const { stream, headers } = res.body;
|
|
72
|
+
const out = new Headers();
|
|
73
|
+
for (const [key, name] of Object.entries(FILE_HEADER_NAMES) as [keyof MemberFileHeaders, string][]) {
|
|
74
|
+
const value = headers[key];
|
|
75
|
+
if (value === null) {
|
|
76
|
+
await stream.cancel();
|
|
77
|
+
return refusal(502);
|
|
78
|
+
}
|
|
79
|
+
out.set(name, value);
|
|
80
|
+
}
|
|
81
|
+
return new Response(stream, { status: 200, headers: out });
|
|
82
|
+
}
|
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
* 401/403/404 (the commerce-disabled build answers 404).
|
|
22
22
|
* - Then any section a module contributes (`accountSettingsSections`).
|
|
23
23
|
*
|
|
24
|
+
* On a spoke that signs in through its hub (`SIGN_IN_PROVIDER === "hub"`,
|
|
25
|
+
* #7894 D10) the account belongs to the hub: the name is a read-only row,
|
|
26
|
+
* Security says sign-in happens on the hub, and both the name edit and the
|
|
27
|
+
* deletion request are a link to the hub's own account settings — so a
|
|
28
|
+
* request about a hub account never lands in this spoke's inbox
|
|
29
|
+
* (SEC-7894-11). Billing is absent there because its read answers 404.
|
|
30
|
+
*
|
|
24
31
|
* No Credentials, Scope, Organizations or Credits section: those are an
|
|
25
32
|
* agent's or an operator's (§5.12a), never a member's.
|
|
26
33
|
*/
|
|
@@ -35,6 +42,8 @@ import type { ReactElement, ReactNode } from "react";
|
|
|
35
42
|
|
|
36
43
|
import { accountSettingsSections } from "../../../../../lib/account-config";
|
|
37
44
|
import { getActiveProviderIds } from "../../../../../lib/auth-env";
|
|
45
|
+
import { hubSessionConfig } from "../../../../../lib/hub-session/config";
|
|
46
|
+
import { SIGN_IN_PROVIDER } from "../../../../../lib/topology.generated";
|
|
38
47
|
import { accountSession, logReadFailure } from "../account-data";
|
|
39
48
|
import { openBillingPortal } from "../actions";
|
|
40
49
|
|
|
@@ -95,6 +104,31 @@ const METHOD_KEYS: Record<string, string> = {
|
|
|
95
104
|
email: "settings.security.password",
|
|
96
105
|
};
|
|
97
106
|
|
|
107
|
+
/** The hub's own account settings page, on a spoke that signs in through it. */
|
|
108
|
+
function hubSettingsHref(): string | null {
|
|
109
|
+
const config = hubSessionConfig();
|
|
110
|
+
return config === null ? null : new URL("/account/settings", config.hubOrigin).toString();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A link to the hub's account settings, in place of a control that would act on the spoke. */
|
|
114
|
+
function HubSettingsLink({
|
|
115
|
+
href,
|
|
116
|
+
label,
|
|
117
|
+
testId,
|
|
118
|
+
}: {
|
|
119
|
+
href: string;
|
|
120
|
+
label: string;
|
|
121
|
+
testId: string;
|
|
122
|
+
}): ReactElement {
|
|
123
|
+
return (
|
|
124
|
+
<div>
|
|
125
|
+
<Link href={href} className="inline-flex min-h-6 items-center" data-testid={testId}>
|
|
126
|
+
{label}
|
|
127
|
+
</Link>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
98
132
|
/** The statuses with a message key; any other renders as the API sent it. */
|
|
99
133
|
const KNOWN_STATUSES: ReadonlySet<string> = new Set(["trialing", "active", "past_due", "canceled"]);
|
|
100
134
|
|
|
@@ -140,6 +174,8 @@ export default async function AccountSettingsPage({ params }: Props): Promise<Re
|
|
|
140
174
|
const sub = await readSubscription(ctx.init);
|
|
141
175
|
const showBilling = sub !== null && (sub.status !== null || sub.portalAvailable);
|
|
142
176
|
const methods = getActiveProviderIds().filter((id) => id in METHOD_KEYS);
|
|
177
|
+
const viaHub = SIGN_IN_PROVIDER === "hub";
|
|
178
|
+
const hubHref = viaHub ? hubSettingsHref() : null;
|
|
143
179
|
|
|
144
180
|
return (
|
|
145
181
|
<div className="flex max-w-3xl flex-col gap-8 py-8" data-testid="account-settings">
|
|
@@ -175,26 +211,49 @@ export default async function AccountSettingsPage({ params }: Props): Promise<Re
|
|
|
175
211
|
value: ctx.user.email,
|
|
176
212
|
"data-testid": "account-settings-email",
|
|
177
213
|
},
|
|
214
|
+
...(viaHub && ctx.user.name
|
|
215
|
+
? [
|
|
216
|
+
{
|
|
217
|
+
label: t("settings.general.name"),
|
|
218
|
+
value: ctx.user.name,
|
|
219
|
+
"data-testid": "account-settings-name",
|
|
220
|
+
},
|
|
221
|
+
]
|
|
222
|
+
: []),
|
|
178
223
|
]}
|
|
179
224
|
/>
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
225
|
+
{viaHub ? (
|
|
226
|
+
hubHref !== null ? (
|
|
227
|
+
<HubSettingsLink
|
|
228
|
+
href={hubHref}
|
|
229
|
+
label={t("settings.general.manageOnHub")}
|
|
230
|
+
testId="account-settings-manage-on-hub"
|
|
231
|
+
/>
|
|
232
|
+
) : null
|
|
233
|
+
) : (
|
|
234
|
+
<NameForm
|
|
235
|
+
initialName={ctx.user.name ?? ""}
|
|
236
|
+
labels={{
|
|
237
|
+
name: t("settings.general.name"),
|
|
238
|
+
placeholder: t("settings.general.namePlaceholder"),
|
|
239
|
+
save: t("settings.general.save"),
|
|
240
|
+
saving: t("settings.general.saving"),
|
|
241
|
+
saved: t("settings.general.saved"),
|
|
242
|
+
unsaved: t("settings.general.unsaved"),
|
|
243
|
+
clean: t("settings.general.clean"),
|
|
244
|
+
required: t("settings.general.nameRequired"),
|
|
245
|
+
error: t("settings.general.saveError"),
|
|
246
|
+
}}
|
|
247
|
+
/>
|
|
248
|
+
)}
|
|
194
249
|
</Section>
|
|
195
250
|
|
|
196
251
|
<Section id="security" title={t("settings.security.title")} lead={t("settings.security.lead")}>
|
|
197
|
-
{
|
|
252
|
+
{viaHub ? (
|
|
253
|
+
<Text as="p" variant="body-sm" tone="muted">
|
|
254
|
+
{t("settings.security.viaHub")}
|
|
255
|
+
</Text>
|
|
256
|
+
) : methods.length > 0 ? (
|
|
198
257
|
<div className="flex flex-col gap-2">
|
|
199
258
|
<Text as="p" variant="body-sm" className="font-medium">
|
|
200
259
|
{t("settings.security.methods")}
|
|
@@ -234,20 +293,30 @@ export default async function AccountSettingsPage({ params }: Props): Promise<Re
|
|
|
234
293
|
{t("settings.privacy.cookies")}
|
|
235
294
|
</Link>
|
|
236
295
|
</div>
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
296
|
+
{viaHub ? (
|
|
297
|
+
hubHref !== null ? (
|
|
298
|
+
<HubSettingsLink
|
|
299
|
+
href={hubHref}
|
|
300
|
+
label={t("settings.general.manageOnHub")}
|
|
301
|
+
testId="account-privacy-manage-on-hub"
|
|
302
|
+
/>
|
|
303
|
+
) : null
|
|
304
|
+
) : (
|
|
305
|
+
<div className="flex flex-col gap-2">
|
|
306
|
+
<Text as="p" variant="body-sm" tone="muted">
|
|
307
|
+
{t("settings.privacy.deletionLead")}
|
|
308
|
+
</Text>
|
|
309
|
+
<DeletionRequest
|
|
310
|
+
labels={{
|
|
311
|
+
action: t("settings.privacy.deletion"),
|
|
312
|
+
subject: t("settings.privacy.deletionSubject"),
|
|
313
|
+
message: t("settings.privacy.deletionMessage"),
|
|
314
|
+
sent: t("settings.privacy.deletionSent"),
|
|
315
|
+
error: t("settings.privacy.deletionError"),
|
|
316
|
+
}}
|
|
317
|
+
/>
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
251
320
|
</Section>
|
|
252
321
|
|
|
253
322
|
{showBilling ? (
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
22
22
|
import { headers } from "next/headers";
|
|
23
23
|
|
|
24
|
-
import {
|
|
24
|
+
import { SIGN_IN_PROVIDER, readSession } from "../../../../lib/topology.generated";
|
|
25
25
|
|
|
26
26
|
/** PII-safe error identity for logs (the `checkout.actions.ts` idiom: `err.name` only). */
|
|
27
27
|
function errorName(err: unknown): string {
|
|
@@ -61,7 +61,10 @@ export async function submitSupportRequest(
|
|
|
61
61
|
_prev: SupportRequestState,
|
|
62
62
|
formData: FormData,
|
|
63
63
|
): Promise<SupportRequestState> {
|
|
64
|
-
|
|
64
|
+
// A hub sign-in spoke's account belongs to the hub, whose own settings take
|
|
65
|
+
// a deletion request (#7894 D10, SEC-7894-11): here it can only be support.
|
|
66
|
+
const kind =
|
|
67
|
+
field(formData, "kind") === "deletion" && SIGN_IN_PROVIDER !== "hub" ? "deletion" : "support";
|
|
65
68
|
const subject = field(formData, "subject");
|
|
66
69
|
const message = field(formData, "message");
|
|
67
70
|
if (!subject || !message || subject.length > MAX_SUBJECT || message.length > MAX_MESSAGE) {
|
|
@@ -70,7 +73,7 @@ export async function submitSupportRequest(
|
|
|
70
73
|
|
|
71
74
|
try {
|
|
72
75
|
const requestHeaders = await headers();
|
|
73
|
-
const session = await
|
|
76
|
+
const session = await readSession(requestHeaders);
|
|
74
77
|
if (!session) return { kind: "error", reason: "failed" };
|
|
75
78
|
|
|
76
79
|
const body = {
|
|
@@ -1,54 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* (authed) segment layout — the operator-dashboard session guard (#4665 D9).
|
|
3
3
|
*
|
|
4
|
-
* RSC: reads the
|
|
5
|
-
* (
|
|
6
|
-
*
|
|
4
|
+
* RSC: reads the session through the generated topology seam's `readSession`
|
|
5
|
+
* (#7894 D10) — Better Auth's `auth.api.getSession` on a hub, the sealed
|
|
6
|
+
* hub-session cookie on a spoke that signs in through its hub. With no
|
|
7
|
+
* session it renders the generated gate seam's `SignedOutGate` rather than
|
|
7
8
|
* redirecting (a `redirect("/sign-in")` would render the visitor's requested
|
|
8
|
-
* path only as a query param; rendering the gate in place keeps the URL
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* (spec Clarifications) — that flag governs whether PUBLIC marketing routes
|
|
9
|
+
* path only as a query param; rendering the gate in place keeps the URL —
|
|
10
|
+
* #4984). This guard reads no public-route auth-requirement flag (spec
|
|
11
|
+
* Clarifications) — that flag governs whether PUBLIC marketing routes
|
|
12
12
|
* redirect; tenant signup data is never public, so this gate always applies.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* When the email sign-in link is on (#7435 § D9 — the same
|
|
20
|
-
* `getActiveProviderIds()` predicate `auth.ts` registers the plugin on), the
|
|
21
|
-
* layout resolves `/sign-in`'s card copy here too and passes it as the gate's
|
|
22
|
-
* `emailLink` prop; otherwise it passes nothing and the gate renders as before.
|
|
23
|
-
*
|
|
24
|
-
* On an account path (#7430, `isAccountPath` on the forwarded `x-pathname`)
|
|
25
|
-
* the visitor is a member, not an operator: the gate reads the member-facing
|
|
26
|
-
* `account.gate` copy instead of `waitlistDashboard.gate` (which asks for
|
|
27
|
-
* provider env vars "to enable operator access"). When no sign-in method is
|
|
28
|
-
* on, the layout renders that member gate itself so its headline is a real
|
|
29
|
-
* `<h1>` — `SignInGate`'s not-configured branch renders `EmptyState`, whose
|
|
30
|
-
* headline is a `<p>` — and its one action leads back to the site rather than
|
|
31
|
-
* to a sign-in page that has nothing to offer. An absent `x-pathname` keeps
|
|
32
|
-
* the operator copy, the same fail-safe direction as the locale layout's
|
|
33
|
-
* account branch.
|
|
14
|
+
* What the gate renders is the seam's (`signed-out-gate.generated.tsx`): on a
|
|
15
|
+
* hub, the operator `SignInGate` or, on an account path, the member gate
|
|
16
|
+
* (#7419, #7430, #7435); on a hub sign-in spoke, the hub-session gate. This
|
|
17
|
+
* layout therefore imports neither `auth.ts` nor the `(auth)` group, so a
|
|
18
|
+
* spoke that keeps its account area keeps this layout too (spec 7894 D9).
|
|
34
19
|
*/
|
|
35
20
|
|
|
36
|
-
import { localeHref } from "@working-theory/ui";
|
|
37
|
-
import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
|
|
38
|
-
import type { AuthPanelProvider } from "@working-theory/ui/sections";
|
|
39
|
-
import { readLocales } from "@working-theory/web-i18n";
|
|
40
21
|
import { headers } from "next/headers";
|
|
41
|
-
import { getTranslations } from "next-intl/server";
|
|
42
22
|
import type { ReactElement, ReactNode } from "react";
|
|
43
23
|
|
|
44
|
-
import {
|
|
45
|
-
import { auth } from "../../../auth";
|
|
46
|
-
import { getActiveProviderIds } from "../../../lib/auth-env";
|
|
47
|
-
import { isAccountPath } from "../../../lib/auth-routes";
|
|
48
|
-
|
|
49
|
-
import { SignInGate } from "./sign-in-gate";
|
|
24
|
+
import { readSession } from "../../../lib/topology.generated";
|
|
50
25
|
|
|
51
|
-
|
|
26
|
+
import { SignedOutGate } from "./signed-out-gate.generated";
|
|
52
27
|
|
|
53
28
|
interface Props {
|
|
54
29
|
children: ReactNode;
|
|
@@ -61,68 +36,11 @@ export default async function AuthedLayout({
|
|
|
61
36
|
}: Props): Promise<ReactElement> {
|
|
62
37
|
const { locale } = await params;
|
|
63
38
|
const requestHeaders = await headers();
|
|
64
|
-
const session = await
|
|
65
|
-
|
|
66
|
-
if (!session) {
|
|
67
|
-
// No `x-pathname`, no locale read: a render outside the middleware keeps
|
|
68
|
-
// the operator gate without touching the locale config.
|
|
69
|
-
const pathname = requestHeaders.get("x-pathname");
|
|
70
|
-
const isAccount =
|
|
71
|
-
pathname !== null && isAccountPath(pathname, readLocales());
|
|
72
|
-
const t = await getTranslations({
|
|
73
|
-
locale,
|
|
74
|
-
namespace: isAccount ? "account.gate" : "waitlistDashboard.gate",
|
|
75
|
-
});
|
|
76
|
-
const active = getActiveProviderIds();
|
|
77
|
-
const emailLink = active.includes("magic-link")
|
|
78
|
-
? { text: await resolveSignInCardText(locale) }
|
|
79
|
-
: undefined;
|
|
80
|
-
const providers = active.filter((id): id is AuthPanelProvider =>
|
|
81
|
-
(SUPPORTED_PROVIDERS as readonly string[]).includes(id),
|
|
82
|
-
);
|
|
83
|
-
const signInPath = localeHref(locale, "/sign-in");
|
|
84
|
-
|
|
85
|
-
if (isAccount && providers.length === 0 && !emailLink) {
|
|
86
|
-
return (
|
|
87
|
-
<div
|
|
88
|
-
className="flex min-h-screen flex-col items-center justify-center gap-4 py-section text-center"
|
|
89
|
-
data-testid="account-sign-in-gate"
|
|
90
|
-
>
|
|
91
|
-
<Heading level={1} visual={4}>
|
|
92
|
-
{t("emptyHeadline")}
|
|
93
|
-
</Heading>
|
|
94
|
-
<Text variant="body-sm" tone="muted" className="max-w-xs">
|
|
95
|
-
{t("emptyDescription")}
|
|
96
|
-
</Text>
|
|
97
|
-
{/* With no sign-in method on, /sign-in has nothing for a member
|
|
98
|
-
either, so the one action goes back to the site. Outline
|
|
99
|
-
action, 44px on a phone and 36px from md up. */}
|
|
100
|
-
<Button asChild variant="secondary" className="max-md:min-h-11">
|
|
101
|
-
<Link
|
|
102
|
-
href={localeHref(locale, "/")}
|
|
103
|
-
variant="bare"
|
|
104
|
-
data-testid="account-sign-in-gate-home"
|
|
105
|
-
>
|
|
106
|
-
{t("homeLink")}
|
|
107
|
-
</Link>
|
|
108
|
-
</Button>
|
|
109
|
-
</div>
|
|
110
|
-
);
|
|
111
|
-
}
|
|
39
|
+
const session = await readSession(requestHeaders);
|
|
112
40
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
emptyHeadline: t("emptyHeadline"),
|
|
117
|
-
emptyDescription: t("emptyDescription"),
|
|
118
|
-
signInLinkLabel: t("signInLink"),
|
|
119
|
-
}}
|
|
120
|
-
signInPath={signInPath}
|
|
121
|
-
providers={providers}
|
|
122
|
-
{...(emailLink ? { emailLink } : {})}
|
|
123
|
-
/>
|
|
124
|
-
);
|
|
125
|
-
}
|
|
41
|
+
// Awaited, not rendered as an element: the gate is a server function of the
|
|
42
|
+
// request, and the layout returns exactly the element it resolves to.
|
|
43
|
+
if (!session) return SignedOutGate({ locale, requestHeaders });
|
|
126
44
|
|
|
127
45
|
// data-profile="app" puts the Pro-host editor surfaces in this group on
|
|
128
46
|
// @working-theory/theme's app profile (#5953); tokens.css's own
|
|
@@ -131,7 +49,7 @@ export default async function AuthedLayout({
|
|
|
131
49
|
// layout returns a fragment and the attribute needs an element.
|
|
132
50
|
//
|
|
133
51
|
// Two things about the placement are deliberate. Only the SIGNED-IN branch
|
|
134
|
-
// carries it:
|
|
52
|
+
// carries it: the gate above is the marketing site's sign-in front door,
|
|
135
53
|
// not an app surface. And the attribute goes on this nested div rather than
|
|
136
54
|
// the app's <html> because apps/web's marketing routes share that root and
|
|
137
55
|
// must stay on the marketing profile — which also makes this half immune to
|