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.
Files changed (184) hide show
  1. package/README.md +124 -0
  2. package/component-catalog.json +3 -3
  3. package/dist/add-module.d.ts +7 -0
  4. package/dist/add-module.js +80 -3
  5. package/dist/add-route.js +26 -2
  6. package/dist/adopt.d.ts +74 -0
  7. package/dist/adopt.js +314 -0
  8. package/dist/apply-design.d.ts +12 -1
  9. package/dist/apply-design.js +36 -4
  10. package/dist/cli.js +103 -3
  11. package/dist/derive-architecture.d.ts +37 -2
  12. package/dist/derive-architecture.js +92 -7
  13. package/dist/design-package.js +3 -2
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/lib/chrome-readers.d.ts +21 -0
  17. package/dist/lib/chrome-readers.js +100 -0
  18. package/dist/lib/declared-env.d.ts +142 -0
  19. package/dist/lib/declared-env.js +985 -0
  20. package/dist/lib/env-catalog.d.ts +17 -0
  21. package/dist/lib/env-catalog.js +234 -0
  22. package/dist/lib/extract-module-bundle.d.ts +20 -0
  23. package/dist/lib/extract-module-bundle.js +53 -6
  24. package/dist/lib/license-key-files.d.ts +6 -1
  25. package/dist/lib/license-key-files.js +9 -4
  26. package/dist/lib/module-tier.d.ts +2 -0
  27. package/dist/lib/module-tier.js +4 -0
  28. package/dist/module-architecture.d.ts +9 -0
  29. package/dist/module-architecture.js +82 -0
  30. package/dist/parse-args.d.ts +27 -0
  31. package/dist/parse-args.js +89 -0
  32. package/dist/programmatic.d.ts +28 -0
  33. package/dist/programmatic.js +181 -11
  34. package/dist/seed-flows.d.ts +65 -0
  35. package/dist/seed-flows.js +350 -0
  36. package/dist/site-chrome.d.ts +4 -0
  37. package/dist/site-chrome.js +34 -3
  38. package/dist/site-manifest.d.ts +55 -5
  39. package/dist/site-manifest.js +121 -12
  40. package/dist/site-roster.d.ts +13 -0
  41. package/dist/site-roster.js +22 -8
  42. package/dist/spoke-checkout.d.ts +42 -0
  43. package/dist/spoke-checkout.js +359 -0
  44. package/dist/surfaces.js +7 -0
  45. package/dist/sync-routes.js +29 -5
  46. package/dist/topology.d.ts +246 -0
  47. package/dist/topology.js +942 -0
  48. package/package.json +3 -3
  49. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  50. package/templates/.env.example +10 -0
  51. package/templates/apps/api/CLAUDE.md +21 -4
  52. package/templates/apps/api/package.json +1 -0
  53. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  55. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  56. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  57. package/templates/apps/api/src/bin/spokes.ts +62 -1
  58. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  59. package/templates/apps/api/src/config/index.ts +35 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
  61. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  62. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  63. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  64. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  65. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  66. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  67. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  68. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  69. package/templates/apps/api/src/lib/site.ts +37 -0
  70. package/templates/apps/api/src/lib/spoke-store.ts +41 -3
  71. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  72. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  73. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  74. package/templates/apps/api/src/middleware/auth.ts +129 -0
  75. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  76. package/templates/apps/api/src/openapi/spec.ts +38 -2
  77. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  78. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  80. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
  82. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  83. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  84. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  85. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  86. package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
  87. package/templates/apps/api/src/server.ts +105 -0
  88. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  89. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  90. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
  91. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  92. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  93. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  94. package/templates/apps/api/src/services/lead-routing.ts +278 -30
  95. package/templates/apps/api/src/services/person.ts +541 -0
  96. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  97. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  98. package/templates/apps/mcp/src/index.ts +6 -4
  99. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  100. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  101. package/templates/apps/web/.env.example +58 -5
  102. package/templates/apps/web/CLAUDE.md +1 -0
  103. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  106. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  107. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  110. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  111. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  112. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  113. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  115. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  116. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  117. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  118. package/templates/apps/web/app/[locale]/layout.tsx +83 -18
  119. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  120. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  121. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  122. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  123. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  124. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  125. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  126. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  127. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  128. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
  129. package/templates/apps/web/app/openapi.json/route.ts +72 -7
  130. package/templates/apps/web/auth.ts +29 -2
  131. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  132. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  133. package/templates/apps/web/instrumentation.ts +7 -4
  134. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  135. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  136. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  137. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  138. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  139. package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
  140. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  141. package/templates/apps/web/lib/account-config.ts +130 -1
  142. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  143. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  144. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  145. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  146. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  147. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  148. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  149. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  150. package/templates/apps/web/lib/listings-config.ts +31 -0
  151. package/templates/apps/web/lib/listings.tsx +7 -3
  152. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  153. package/templates/apps/web/lib/next-path.ts +35 -0
  154. package/templates/apps/web/lib/relay.ts +209 -3
  155. package/templates/apps/web/lib/server-api.ts +11 -1
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +3 -0
  158. package/templates/apps/web/lib/topology.generated.ts +33 -0
  159. package/templates/apps/web/messages/en.json +32 -3
  160. package/templates/apps/web/package.json +1 -1
  161. package/templates/database/CHANGELOG.md +51 -0
  162. package/templates/database/__tests__/agents.db.test.ts +85 -0
  163. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  164. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  165. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  166. package/templates/database/__tests__/person.db.test.ts +584 -0
  167. package/templates/database/auth/schema.prisma +24 -0
  168. package/templates/database/config/schema.prisma +13 -0
  169. package/templates/database/inbox/schema.prisma +111 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  175. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  176. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  177. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  178. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  179. package/templates/database/package.json +1 -1
  180. package/templates/database/scripts/person-backfill.sql +132 -0
  181. package/templates/database/tenancy.ts +53 -0
  182. package/templates/tools/check-declared-env.ts +37 -0
  183. package/templates/tools/declared-env.ts +1163 -0
  184. package/versions.json +35 -34
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Sealed cookie values for the spoke's hub session (#7894 D7).
3
+ *
4
+ * AES-256-GCM with a fresh 12-byte IV per seal. The key is HKDF-SHA256 of
5
+ * `AILK_HUB_SESSION_SECRET` with one info string per cookie purpose, so the
6
+ * session cookie and the flow cookie never share a key, and the cookie's NAME
7
+ * is the additional authenticated data, so a value sealed for one cookie never
8
+ * opens as the other. The key never derives from the client secret: a leaked
9
+ * client secret forges no spoke session.
10
+ *
11
+ * Wire form: base64url(iv ‖ tag ‖ ciphertext). Anything that does not open —
12
+ * a wrong key, a wrong name, a truncated or altered value — reads as `null`,
13
+ * never a throw. Node `crypto` only; no dependency.
14
+ */
15
+
16
+ import {
17
+ createCipheriv,
18
+ createDecipheriv,
19
+ hkdfSync,
20
+ randomBytes,
21
+ } from "node:crypto";
22
+
23
+ /** The two things this module seals, each under its own derived key. */
24
+ export type SealPurpose = "session" | "flow";
25
+
26
+ const INFO: Record<SealPurpose, string> = {
27
+ session: "ailk-hub-session/v1",
28
+ flow: "ailk-hub-flow/v1",
29
+ };
30
+
31
+ const IV_BYTES = 12;
32
+ const TAG_BYTES = 16;
33
+
34
+ function deriveKey(secret: string, purpose: SealPurpose): Buffer {
35
+ return Buffer.from(
36
+ hkdfSync("sha256", secret, Buffer.alloc(0), INFO[purpose], 32),
37
+ );
38
+ }
39
+
40
+ /** `value` as JSON, sealed for the cookie `name`. */
41
+ export function seal(
42
+ value: unknown,
43
+ secret: string,
44
+ purpose: SealPurpose,
45
+ name: string,
46
+ ): string {
47
+ const iv = randomBytes(IV_BYTES);
48
+ const cipher = createCipheriv("aes-256-gcm", deriveKey(secret, purpose), iv);
49
+ cipher.setAAD(Buffer.from(name, "utf8"));
50
+ const body = Buffer.concat([
51
+ cipher.update(JSON.stringify(value), "utf8"),
52
+ cipher.final(),
53
+ ]);
54
+ return Buffer.concat([iv, cipher.getAuthTag(), body]).toString("base64url");
55
+ }
56
+
57
+ /** The JSON value `sealed` holds for the cookie `name`, or `null` when it does not open. */
58
+ export function unseal(
59
+ sealed: string,
60
+ secret: string,
61
+ purpose: SealPurpose,
62
+ name: string,
63
+ ): unknown {
64
+ try {
65
+ const raw = Buffer.from(sealed, "base64url");
66
+ if (raw.length <= IV_BYTES + TAG_BYTES) return null;
67
+ const decipher = createDecipheriv(
68
+ "aes-256-gcm",
69
+ deriveKey(secret, purpose),
70
+ raw.subarray(0, IV_BYTES),
71
+ );
72
+ decipher.setAAD(Buffer.from(name, "utf8"));
73
+ decipher.setAuthTag(raw.subarray(IV_BYTES, IV_BYTES + TAG_BYTES));
74
+ const text = Buffer.concat([
75
+ decipher.update(raw.subarray(IV_BYTES + TAG_BYTES)),
76
+ decipher.final(),
77
+ ]).toString("utf8");
78
+ return JSON.parse(text) as unknown;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The spoke's hub session (#7894 D7, D12, D13) — read, forwarded, never stored.
3
+ *
4
+ * A spoke that signs in through its hub has no database: after the callback
5
+ * route exchanges a code, the hub-issued credential and the person it names
6
+ * live in ONE sealed, `HttpOnly` cookie. This module is everything that reads
7
+ * it:
8
+ *
9
+ * - `readHubSession(headers)` — the web seam's `readSession` on a hub-mode
10
+ * spoke: unseals the cookie from the `Cookie` header, no network. Expired,
11
+ * unreadable or sealed under another key ⇒ `null`.
12
+ * - `hubSessionInit()` — what `forwardedCookieInit()` returns on such a
13
+ * spoke: the credential as `Authorization: Bearer`, the ONE request-init
14
+ * re-target the account area reads the hub through (D12). The api-client
15
+ * adds the spoke key itself; the hub admits the credential only beside it.
16
+ * - `hubSessionBuyer(request)` — a customer the hub vouches for at buy time
17
+ * (D13): read live from `GET /v1/hub-sign-in/me`, never from the cookie.
18
+ *
19
+ * Nothing here logs a credential, a cookie value or an email.
20
+ */
21
+
22
+ import { hubSignIn } from "@working-theory/api-client";
23
+ import { headers as requestHeaders } from "next/headers";
24
+
25
+ import { resolveNextPath } from "../next-path";
26
+
27
+ import { type HubSessionConfig, hubSessionConfig } from "./config";
28
+ import { unseal } from "./seal";
29
+
30
+ /** The session cookie: `__Host-` on an https site, plain on a loopback one. */
31
+ export function sessionCookieName(config: Pick<HubSessionConfig, "secure">): string {
32
+ return config.secure ? "__Host-ailk-hub-session" : "ailk-hub-session";
33
+ }
34
+
35
+ /** The flow cookie that carries state, verifier and `next` across the hub hop. */
36
+ export function flowCookieName(config: Pick<HubSessionConfig, "secure">): string {
37
+ return config.secure ? "__Host-ailk-hub-flow" : "ailk-hub-flow";
38
+ }
39
+
40
+ /** The person a hub session names — the fields the account area reads. */
41
+ export interface HubSessionUser {
42
+ name: string | null;
43
+ email: string;
44
+ emailVerified: boolean;
45
+ createdAt: string;
46
+ }
47
+
48
+ /** What the session cookie holds (sealed). */
49
+ export interface HubSessionPayload {
50
+ v: 1;
51
+ accessToken: string;
52
+ /** Epoch milliseconds; the cookie's own `Max-Age` ends at the same time. */
53
+ expiresAt: number;
54
+ user: HubSessionUser;
55
+ }
56
+
57
+ /** The value of cookie `name` in a `Cookie` header, or `undefined`. */
58
+ export function readCookie(header: string | null, name: string): string | undefined {
59
+ if (header === null) return undefined;
60
+ for (const part of header.split(";")) {
61
+ const at = part.indexOf("=");
62
+ if (at === -1) continue;
63
+ if (part.slice(0, at).trim() === name) return part.slice(at + 1).trim();
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ /**
69
+ * A `Set-Cookie` value for one of this module's cookies — always `HttpOnly`,
70
+ * `SameSite=Lax` (the hub's top-level redirect back must carry the flow
71
+ * cookie) and `Path=/`; `Secure` on an https site. `maxAge` 0 clears it.
72
+ */
73
+ export function cookieHeader(
74
+ name: string,
75
+ value: string,
76
+ maxAge: number,
77
+ config: Pick<HubSessionConfig, "secure">,
78
+ ): string {
79
+ return [
80
+ `${name}=${value}`,
81
+ "Path=/",
82
+ "HttpOnly",
83
+ "SameSite=Lax",
84
+ `Max-Age=${Math.max(0, Math.floor(maxAge))}`,
85
+ ...(config.secure ? ["Secure"] : []),
86
+ ].join("; ");
87
+ }
88
+
89
+ function isPayload(value: unknown): value is HubSessionPayload {
90
+ if (typeof value !== "object" || value === null) return false;
91
+ const v = value as Record<string, unknown>;
92
+ const user = v["user"] as Record<string, unknown> | null | undefined;
93
+ return (
94
+ v["v"] === 1 &&
95
+ typeof v["accessToken"] === "string" &&
96
+ typeof v["expiresAt"] === "number" &&
97
+ typeof user === "object" &&
98
+ user !== null &&
99
+ typeof user["email"] === "string" &&
100
+ typeof user["emailVerified"] === "boolean" &&
101
+ (user["name"] === null || typeof user["name"] === "string") &&
102
+ typeof user["createdAt"] === "string"
103
+ );
104
+ }
105
+
106
+ /** The sealed session in `headers`, or `null` — not configured, absent, expired or unreadable. */
107
+ export function readHubSessionPayload(headers: Headers): HubSessionPayload | null {
108
+ const config = hubSessionConfig();
109
+ if (config === null) return null;
110
+ const name = sessionCookieName(config);
111
+ const sealed = readCookie(headers.get("cookie"), name);
112
+ if (sealed === undefined || sealed === "") return null;
113
+ const value = unseal(sealed, config.sessionSecret, "session", name);
114
+ if (!isPayload(value) || value.expiresAt <= Date.now()) return null;
115
+ return value;
116
+ }
117
+
118
+ /** The web seam's `readSession` on a hub-mode spoke: the person, or `null`. */
119
+ export function readHubSession(
120
+ headers: Headers,
121
+ ): Promise<{ user: HubSessionUser } | null> {
122
+ const payload = readHubSessionPayload(headers);
123
+ return Promise.resolve(payload === null ? null : { user: payload.user });
124
+ }
125
+
126
+ /**
127
+ * `forwardedCookieInit()` on a hub-mode spoke (D12): the credential as a
128
+ * Bearer header signed in, no header at all signed out. Never the visitor's
129
+ * cookie — the hub has no session for it.
130
+ */
131
+ export async function hubSessionInit(): Promise<{ headers: Record<string, string> }> {
132
+ const payload = readHubSessionPayload(await requestHeaders());
133
+ return payload === null
134
+ ? { headers: {} }
135
+ : { headers: { authorization: `Bearer ${payload.accessToken}` } };
136
+ }
137
+
138
+ /**
139
+ * The customer the hub vouches for, for a spoke's buy route (D13): read live
140
+ * from `GET /v1/hub-sign-in/me` with the person's credential (the api-client
141
+ * adds the spoke key). `null` with no session, when the hub refuses the
142
+ * credential (a revoked one reads as signed out here), when it names no
143
+ * customer, or on any failure — a buyer is never taken from the cookie.
144
+ */
145
+ export async function hubSessionBuyer(
146
+ request: Request,
147
+ ): Promise<{ customerId: string } | null> {
148
+ const payload = readHubSessionPayload(request.headers);
149
+ if (payload === null) return null;
150
+ try {
151
+ const res = await hubSignIn.me({
152
+ headers: { authorization: `Bearer ${payload.accessToken}` },
153
+ });
154
+ if (!res.ok) return null;
155
+ const customerId = res.body.billingCustomerId;
156
+ return typeof customerId === "string" && customerId !== ""
157
+ ? { customerId }
158
+ : null;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ /** Where to land after signing in, when `next` is missing or refused. */
165
+ export const HUB_SESSION_FALLBACK_PATH = "/account";
166
+
167
+ /**
168
+ * The post-sign-in path for `raw`: `resolveNextPath`'s same-origin rule,
169
+ * printable ASCII only (it becomes a `Location` header), and never a path
170
+ * that resolves under `/api` — dot segments included — so a sign-in lands on
171
+ * a page and never re-enters a route handler.
172
+ */
173
+ export function hubNextPath(raw: unknown): string {
174
+ const next = resolveNextPath(raw, HUB_SESSION_FALLBACK_PATH);
175
+ if (!/^[\x21-\x7e]+$/.test(next)) return HUB_SESSION_FALLBACK_PATH;
176
+ const path = new URL(next, "http://site.invalid").pathname;
177
+ return path === "/api" || path.startsWith("/api/")
178
+ ? HUB_SESSION_FALLBACK_PATH
179
+ : next;
180
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * HubSignInGate — the signed-out account gate of a spoke that signs in
3
+ * through its hub (#7894 D10).
4
+ *
5
+ * A Server Component, rendered by the `(authed)` layout through the generated
6
+ * gate seam (`signed-out-gate.generated.tsx`, its `signIn: "hub"` render). It
7
+ * offers ONE action: a link to this site's own `/api/hub-session/sign-in`,
8
+ * carrying the requested path as `next`, which starts the hub sign-in. The
9
+ * link is a plain anchor (the `Link` primitive), so nothing prefetches the
10
+ * route and starts a flow the visitor did not ask for.
11
+ *
12
+ * Not configured (`hubSessionConfig()` is `null`: a client id, secret or
13
+ * session secret missing), it renders the account area's existing "sign-in
14
+ * isn't available yet" state, whose one action leads back to the site.
15
+ */
16
+
17
+ import { localeHref } from "@working-theory/ui";
18
+ import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
19
+ import { getTranslations } from "next-intl/server";
20
+ import type { ReactElement } from "react";
21
+
22
+ import { hubSessionConfig } from "./config";
23
+ import { HUB_SESSION_FALLBACK_PATH } from "./session";
24
+
25
+ /** The route that starts the hub sign-in. */
26
+ const HUB_SIGN_IN_ROUTE = "/api/hub-session/sign-in";
27
+
28
+ export interface HubSignInGateProps {
29
+ locale: string;
30
+ /** The incoming request's headers; `x-pathname` is the page to return to. */
31
+ requestHeaders: Headers;
32
+ }
33
+
34
+ export async function HubSignInGate({
35
+ locale,
36
+ requestHeaders,
37
+ }: HubSignInGateProps): Promise<ReactElement> {
38
+ const t = await getTranslations({ locale, namespace: "account.gate" });
39
+ const configured = hubSessionConfig() !== null;
40
+ const next =
41
+ requestHeaders.get("x-pathname") ??
42
+ localeHref(locale, HUB_SESSION_FALLBACK_PATH);
43
+
44
+ return (
45
+ <div
46
+ className="flex min-h-screen flex-col items-center justify-center gap-4 py-section text-center"
47
+ data-testid="account-sign-in-gate"
48
+ >
49
+ <Heading level={1} visual={4}>
50
+ {configured ? t("hubHeadline") : t("emptyHeadline")}
51
+ </Heading>
52
+ <Text variant="body-sm" tone="muted" className="max-w-xs">
53
+ {configured ? t("hubDescription") : t("emptyDescription")}
54
+ </Text>
55
+ {configured ? (
56
+ // The primary action: 44px on a phone, 36px from md up.
57
+ <Button asChild className="max-md:min-h-11">
58
+ <Link
59
+ href={`${HUB_SIGN_IN_ROUTE}?next=${encodeURIComponent(next)}`}
60
+ variant="bare"
61
+ data-testid="account-hub-sign-in"
62
+ >
63
+ {t("hubSignIn")}
64
+ </Link>
65
+ </Button>
66
+ ) : (
67
+ <Button asChild variant="secondary" className="max-md:min-h-11">
68
+ <Link
69
+ href={localeHref(locale, "/")}
70
+ variant="bare"
71
+ data-testid="account-sign-in-gate-home"
72
+ >
73
+ {t("homeLink")}
74
+ </Link>
75
+ </Button>
76
+ )}
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The listing site key — the ONE reader (#7872 D8).
3
+ *
4
+ * The listicle directory (`./listings.tsx`, #7521) switches on this key:
5
+ * declared, it renders; absent, it is off and makes no read. Every reader of
6
+ * the key goes through here, so two copies cannot drift.
7
+ *
8
+ * 1. `NEXT_PUBLIC_AILK_LISTING_SITE_KEY` — the name the spoke roadmap fixes
9
+ * (§7.3). The value names a site and grants nothing (#5048 D2), so it is
10
+ * public by design, and a client consumer gets it inlined at build.
11
+ * 2. `AILK_LISTING_PUBLIC_KEY` — the server-only name every site
12
+ * configured since create-ailk 0.12.0 already sets, kept as a fallback
13
+ * so an upgrade does not switch a working directory off.
14
+ *
15
+ * An empty or whitespace value counts as unset. Both reads use dot access, so
16
+ * Next inlines the public name at build: changing or rotating it needs a
17
+ * rebuild, and while it is set a runtime change to the fallback has no effect.
18
+ */
19
+
20
+ /** The site's listing key, trimmed, or `undefined` when neither name holds one. */
21
+ export function listingSiteKey(): string | undefined {
22
+ const declared = process.env.NEXT_PUBLIC_AILK_LISTING_SITE_KEY?.trim();
23
+ if (declared) return declared;
24
+ const fallback = process.env.AILK_LISTING_PUBLIC_KEY?.trim();
25
+ return fallback ? fallback : undefined;
26
+ }
27
+
28
+ /** Whether the listicle directory is switched on for this site. */
29
+ export function listingsEnabled(): boolean {
30
+ return listingSiteKey() !== undefined;
31
+ }
@@ -20,8 +20,11 @@
20
20
  * BASE TREE, and inert without two things (D7). It ships in every scaffold,
21
21
  * Free included, and is never slice-owned — the layout imports it, so owning
22
22
  * it would strip every page from a keyless scaffold. Each member answers
23
- * `null` with NO read when `AILK_LISTING_PUBLIC_KEY` is unset or the paid
24
- * `@working-theory/ui-signal-collectors` package is absent. The package is
23
+ * `null` with NO read when the site's listing key is unset or the paid
24
+ * `@working-theory/ui-signal-collectors` package is absent. The key is the
25
+ * switch: `./listings-config.ts` is its one reader
26
+ * (`NEXT_PUBLIC_AILK_LISTING_SITE_KEY`, falling back to
27
+ * `AILK_LISTING_PUBLIC_KEY`, #7872 D8). The package is
25
28
  * reached the way `src/lib/commerce-sections.ts` reaches its own: through
26
29
  * `loadUiSignalCollectors()` in the generated paid-sections registry (#7536),
27
30
  * a bundled `import()` where the site carries the package and a stub that
@@ -64,6 +67,7 @@ import { cache, type ComponentType, type ReactElement, type ReactNode } from "re
64
67
 
65
68
  import { resolveBaseUrl } from "../src/lib/routes";
66
69
 
70
+ import { listingSiteKey } from "./listings-config";
67
71
  import { loadUiSignalCollectors } from "./paid-sections.generated";
68
72
 
69
73
  // ── The paid sections, through the generated registry ──────────────────────
@@ -252,7 +256,7 @@ export function entryJsonLd(entry: ListingEntryPublic, entryType: string, url: s
252
256
  // ── The extension ───────────────────────────────────────────────────────────
253
257
 
254
258
  export function createListingExtension(deps: ListingExtensionDeps = {}): ListingExtension {
255
- const siteKey = deps.siteKey ?? (() => process.env["AILK_LISTING_PUBLIC_KEY"]);
259
+ const siteKey = deps.siteKey ?? listingSiteKey;
256
260
  const pathname =
257
261
  deps.pathname ?? (async () => (await headers()).get("x-pathname"));
258
262
  const locales = deps.locales ?? (() => readLocales());
@@ -17,10 +17,18 @@
17
17
  * A duplicated inline call in both pages is how the two would drift, and
18
18
  * it is also how one of them would forget the fail-closed arm — this is
19
19
  * the ONE place that reasoning lives (D5).
20
+ *
21
+ * ON A SPOKE (#7872 D4), `null` means `[]`: the `SCHEDULING_CAL_LINK`
22
+ * fallback is not consulted. The hub books a spoke's slug only when that
23
+ * spoke's workspace has stored it (`spokeMayBook`), so a fallback slug would
24
+ * advertise a meeting whose every booking fails with `event_not_found`. The
25
+ * other arms are the same on both topologies.
20
26
  */
21
27
  import { schedule } from "@working-theory/api-client";
22
28
  import { MeetingSlugSchema } from "@working-theory/site-config/schema";
23
29
 
30
+ import { TOPOLOGY } from "./topology.generated";
31
+
24
32
  /**
25
33
  * The D4 row-1 fallback: the slug half of the env-configured booker's own
26
34
  * calLink (`"<owner>/<slug>"`), or `[]` when the env var is unset. This is
@@ -49,5 +57,7 @@ function derivedFallbackSlugs(): string[] {
49
57
  export async function enabledMeetingSlugs(): Promise<string[]> {
50
58
  const res = await schedule.meetingTypes();
51
59
  if (!res.ok) return [];
52
- return res.body.slugs ?? derivedFallbackSlugs();
60
+ const { slugs } = res.body;
61
+ if (slugs) return slugs;
62
+ return TOPOLOGY === "spoke" ? [] : derivedFallbackSlugs();
53
63
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `?next=` sanitizer for the /sign-in page (#4984) and the hub-session
3
+ * callback (#7894 D7 — moved here from `app/[locale]/(auth)/sign-in/`, which
4
+ * re-exports it, so a spoke that drops the `(auth)` group keeps it).
5
+ *
6
+ * The post-sign-in `callbackURL` handed to Better Auth is caller-controlled
7
+ * via the `next` search param, so it must be a SAME-ORIGIN, SAME-APP path —
8
+ * never a foreign origin (open redirect), a protocol-relative URL, a
9
+ * backslash trick browsers normalise to `//`, or a control character that
10
+ * could split a header. Anything that fails falls back to `fallback`.
11
+ */
12
+
13
+ // U+0000–U+001F and U+007F — the header-splitting / URL-mangling range.
14
+ // eslint-disable-next-line no-control-regex -- the control range is the thing being rejected
15
+ const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
16
+
17
+ /**
18
+ * Returns `raw` when it is a safe in-app path, else `fallback`.
19
+ *
20
+ * Accepts: a string starting with a single "/" (e.g. `/account`,
21
+ * `/en/account?tab=x`, `/waitlist#a`).
22
+ * Rejects: non-strings (incl. Next's `string[]` for a repeated param), "",
23
+ * `//host` (protocol-relative), `/\host` (backslash normalised to `//`),
24
+ * any backslash anywhere, whitespace, control characters, and anything not
25
+ * starting with "/" (`https://…`, `host.tld`).
26
+ */
27
+ export function resolveNextPath(raw: unknown, fallback: string): string {
28
+ if (typeof raw !== "string") return fallback;
29
+ if (!raw.startsWith("/")) return fallback;
30
+ if (raw.startsWith("//")) return fallback;
31
+ if (raw.includes("\\")) return fallback;
32
+ if (/\s/.test(raw)) return fallback;
33
+ if (CONTROL_CHARS.test(raw)) return fallback;
34
+ return raw;
35
+ }