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
|
@@ -32,22 +32,41 @@
|
|
|
32
32
|
// failure rejects — a 401/403 for a verified email included — so the area
|
|
33
33
|
// shows an error row with Retry instead of an empty Inbox (D12). Scheduled
|
|
34
34
|
// (the old Upcoming) stays empty in the base area; a module fills it.
|
|
35
|
+
//
|
|
36
|
+
// The member area's spoke leg (#7920) — the client's own records on the hub,
|
|
37
|
+
// read by a spoke that signs its clients in through the hub (#7877, #7894):
|
|
38
|
+
//
|
|
39
|
+
// - Inbox kinds `piece` (`GET /v1/member/pieces`) and `message`
|
|
40
|
+
// (`GET /v1/member/messages`). A message with a file has a file action to
|
|
41
|
+
// this site's own member file read, `account/inbox/message/[id]/file`,
|
|
42
|
+
// which re-serves the hub's bytes — the browser holds no hub credential.
|
|
43
|
+
// - For-you source `piece-needs-client` — each piece waiting on the client,
|
|
44
|
+
// with an Open action to its Inbox row. It shares the pieces read.
|
|
45
|
+
//
|
|
46
|
+
// The hub admits these reads only with the client's hub credential, which
|
|
47
|
+
// `ctx.init` carries as `Authorization` on such a spoke and nowhere else. On a
|
|
48
|
+
// cookie-session site (a hub, or a site with its own sign-in) there is none,
|
|
49
|
+
// so the three make no call and load `[]` — inert, never an error row.
|
|
35
50
|
|
|
36
51
|
import {
|
|
37
52
|
flowCheckouts,
|
|
53
|
+
member,
|
|
38
54
|
subscription,
|
|
39
55
|
waitlistSignups,
|
|
40
56
|
type FlowCheckoutMeItem,
|
|
41
57
|
type WaitlistSignupMeResponse,
|
|
42
58
|
} from "@working-theory/api-client";
|
|
43
59
|
import type {
|
|
60
|
+
AccountContext,
|
|
44
61
|
AccountForYouSource,
|
|
45
62
|
AccountInboxKind,
|
|
46
63
|
AccountSettingsSection,
|
|
47
64
|
AccountUser,
|
|
48
65
|
} from "@working-theory/templates/account";
|
|
49
66
|
import { localeHref } from "@working-theory/ui";
|
|
67
|
+
import type { MemberMessage, MemberPiece } from "@working-theory/validation";
|
|
50
68
|
import { formatMoney, formatNumber } from "@working-theory/web-i18n";
|
|
69
|
+
import { cache } from "react";
|
|
51
70
|
|
|
52
71
|
import { unwrapApiResult } from "./server-api";
|
|
53
72
|
|
|
@@ -168,6 +187,92 @@ const purchase: AccountInboxKind<FlowCheckoutMeItem> = {
|
|
|
168
187
|
},
|
|
169
188
|
};
|
|
170
189
|
|
|
190
|
+
// ── Member Inbox kinds (#7920) ───────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/** Whether `init` carries the client's hub credential (see the header). */
|
|
193
|
+
function hasHubCredential({ init }: AccountContext): boolean {
|
|
194
|
+
return Object.keys(init.headers).some((name) => name.toLowerCase() === "authorization");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** One pieces read per request — the `piece` kind and the For-you source share it. */
|
|
198
|
+
const memberPieces = cache((init: AccountContext["init"]) => member.pieces(init));
|
|
199
|
+
|
|
200
|
+
async function loadPieces(ctx: AccountContext): Promise<MemberPiece[]> {
|
|
201
|
+
if (!hasHubCredential(ctx)) return [];
|
|
202
|
+
return (await read(memberPieces(ctx.init), ctx.user))?.items ?? [];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const piece: AccountInboxKind<MemberPiece> = {
|
|
206
|
+
kind: "piece",
|
|
207
|
+
label: "pieces.heading",
|
|
208
|
+
section: "updates",
|
|
209
|
+
async load(ctx) {
|
|
210
|
+
return (await loadPieces(ctx)).map((p) => ({
|
|
211
|
+
id: p.id,
|
|
212
|
+
title: p.title,
|
|
213
|
+
at: p.updatedAt,
|
|
214
|
+
data: p,
|
|
215
|
+
}));
|
|
216
|
+
},
|
|
217
|
+
renderDetail({ data }, { locale, t }) {
|
|
218
|
+
return {
|
|
219
|
+
status: data.stage,
|
|
220
|
+
...(data.needsClient ? { lead: t("pieces.needsClient") } : {}),
|
|
221
|
+
fields: {
|
|
222
|
+
heading: t("pieces.heading"),
|
|
223
|
+
items: [
|
|
224
|
+
{ label: t("pieces.stage"), value: data.stage },
|
|
225
|
+
{ label: t("pieces.createdAt"), value: formatWhen(data.createdAt, locale) },
|
|
226
|
+
{ label: t("pieces.updatedAt"), value: formatWhen(data.updatedAt, locale) },
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const message: AccountInboxKind<MemberMessage> = {
|
|
234
|
+
kind: "message",
|
|
235
|
+
label: "messages.heading",
|
|
236
|
+
section: "updates",
|
|
237
|
+
async load(ctx) {
|
|
238
|
+
if (!hasHubCredential(ctx)) return [];
|
|
239
|
+
const messages = (await read(member.messages(ctx.init), ctx.user))?.items ?? [];
|
|
240
|
+
return messages.map((m) => ({
|
|
241
|
+
id: m.id,
|
|
242
|
+
title: m.subject,
|
|
243
|
+
at: m.createdAt,
|
|
244
|
+
data: m,
|
|
245
|
+
}));
|
|
246
|
+
},
|
|
247
|
+
renderDetail({ data }, { locale, t }) {
|
|
248
|
+
return {
|
|
249
|
+
lead: data.body,
|
|
250
|
+
...(data.file
|
|
251
|
+
? {
|
|
252
|
+
actions: [
|
|
253
|
+
{
|
|
254
|
+
key: "file",
|
|
255
|
+
label: t("messages.download", { filename: data.file.filename }),
|
|
256
|
+
variant: "default" as const,
|
|
257
|
+
href: localeHref(
|
|
258
|
+
locale,
|
|
259
|
+
`/account/inbox/message/${encodeURIComponent(data.id)}/file`,
|
|
260
|
+
),
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
}
|
|
264
|
+
: {}),
|
|
265
|
+
fields: {
|
|
266
|
+
heading: t("messages.heading"),
|
|
267
|
+
items: [
|
|
268
|
+
{ label: t("messages.sentAt"), value: formatWhen(data.createdAt, locale) },
|
|
269
|
+
...(data.file ? [{ label: t("messages.file"), value: data.file.filename }] : []),
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
|
|
171
276
|
// ── Base For-you sources (D9 as amended, D8a) ────────────────────────────────
|
|
172
277
|
|
|
173
278
|
const emailUnverified: AccountForYouSource = {
|
|
@@ -206,10 +311,33 @@ const paymentDue: AccountForYouSource = {
|
|
|
206
311
|
},
|
|
207
312
|
};
|
|
208
313
|
|
|
314
|
+
// ── Member For-you source (#7920) ────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
const pieceNeedsClient: AccountForYouSource = {
|
|
317
|
+
id: "piece-needs-client",
|
|
318
|
+
async load(ctx) {
|
|
319
|
+
const { locale, t } = ctx;
|
|
320
|
+
return (await loadPieces(ctx))
|
|
321
|
+
.filter((p) => p.needsClient)
|
|
322
|
+
.map((p) => ({
|
|
323
|
+
id: `piece:${p.id}`,
|
|
324
|
+
type: { id: "piece", label: t("pieces.type"), tone: "needs-you" },
|
|
325
|
+
title: p.title,
|
|
326
|
+
// The read carries no time the piece began waiting — its last update.
|
|
327
|
+
time: { kind: "at", at: p.updatedAt },
|
|
328
|
+
action: {
|
|
329
|
+
kind: "open",
|
|
330
|
+
href: localeHref(locale, `/account/inbox/piece/${encodeURIComponent(p.id)}`),
|
|
331
|
+
},
|
|
332
|
+
dismissible: true,
|
|
333
|
+
}));
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
|
|
209
337
|
// ── Contributions ─────────────────────────────────────────────────────────────
|
|
210
338
|
|
|
211
339
|
/** Inbox item kinds, in load order. A module appends its own. */
|
|
212
|
-
export const accountInboxKinds: AccountInboxKind[] = [result, purchase];
|
|
340
|
+
export const accountInboxKinds: AccountInboxKind[] = [result, purchase, piece, message];
|
|
213
341
|
|
|
214
342
|
/** User-settings sections a module adds after the base ones. None in the base area. */
|
|
215
343
|
export const accountSettingsSections: AccountSettingsSection[] = [];
|
|
@@ -218,4 +346,5 @@ export const accountSettingsSections: AccountSettingsSection[] = [];
|
|
|
218
346
|
export const accountForYouSources: AccountForYouSource[] = [
|
|
219
347
|
emailUnverified,
|
|
220
348
|
paymentDue,
|
|
349
|
+
pieceNeedsClient,
|
|
221
350
|
];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// api-origin.generated.ts — the browser-facing API origin this site's server reads.
|
|
2
|
+
//
|
|
3
|
+
// GENERATED by create-ailk for this site's topology (#7874). Do not edit:
|
|
4
|
+
// every scaffold rewrites it. A hub reads NEXT_PUBLIC_AILK_API_URL as a
|
|
5
|
+
// fallback API origin; a spoke (`--topology spoke`) has no browser-facing
|
|
6
|
+
// API origin — its hub origin is server-only (AILK_API_URL) — so its render
|
|
7
|
+
// reads no environment variable.
|
|
8
|
+
|
|
9
|
+
export function browserApiOrigin(): string | undefined {
|
|
10
|
+
return process.env.NEXT_PUBLIC_AILK_API_URL;
|
|
11
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a page ends on the closing footer (#7797).
|
|
3
|
+
*
|
|
4
|
+
* The `closingFooterPaths` chrome knob lists route paths as the site writes
|
|
5
|
+
* them ("/about"). The visitor's pathname arrives as the middleware forwards it, so
|
|
6
|
+
* it may carry a locale prefix ("/es/about") or a trailing slash ("/about/").
|
|
7
|
+
* Both sides are reduced to the same form before they are compared, so the
|
|
8
|
+
* list names a page once however a request spells it.
|
|
9
|
+
*
|
|
10
|
+
* No Next.js import — pure functions, asserted directly by their own suite,
|
|
11
|
+
* in the `profile-back-link.ts` idiom.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** `path` without its query or fragment, any enabled-locale prefix, or trailing slashes; "/" for the root. */
|
|
15
|
+
export function normalizeRoutePath(
|
|
16
|
+
path: string,
|
|
17
|
+
locales: readonly string[],
|
|
18
|
+
): string {
|
|
19
|
+
const bare = path.split(/[?#]/)[0] ?? "";
|
|
20
|
+
// Compare the first segment to the locale list rather than building a
|
|
21
|
+
// pattern from it, so a locale code is never read as regex syntax.
|
|
22
|
+
const segments = bare.split("/");
|
|
23
|
+
const first = segments[1];
|
|
24
|
+
const unprefixed =
|
|
25
|
+
first !== undefined && locales.includes(first)
|
|
26
|
+
? ["", ...segments.slice(2)].join("/")
|
|
27
|
+
: bare;
|
|
28
|
+
const trimmed = unprefixed.replace(/\/+$/, "");
|
|
29
|
+
return trimmed === "" ? "/" : trimmed;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* True when `pathname` is one of `paths` and is not the home.
|
|
34
|
+
*
|
|
35
|
+
* Fail-safe direction: an absent or empty list, or an absent pathname, answers
|
|
36
|
+
* `false`, so a site that sets no list renders exactly as before. The home is
|
|
37
|
+
* never an inner page, so listing "/" has no effect — the home's footer is its
|
|
38
|
+
* own knobs' (`home`, `chromeStyle`).
|
|
39
|
+
*/
|
|
40
|
+
export function isClosingFooterPath(input: {
|
|
41
|
+
pathname: string | null | undefined;
|
|
42
|
+
paths: readonly string[] | undefined;
|
|
43
|
+
locales: readonly string[];
|
|
44
|
+
}): boolean {
|
|
45
|
+
const { pathname, paths, locales } = input;
|
|
46
|
+
if (!pathname || !paths || paths.length === 0) return false;
|
|
47
|
+
const current = normalizeRoutePath(pathname, locales);
|
|
48
|
+
if (current === "/") return false;
|
|
49
|
+
return paths.some((path) => normalizeRoutePath(path, locales) === current);
|
|
50
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The spoke's hub-session module (#7894 D7, D12, D13; spec
|
|
7
|
+
* docs/specs/7894-the-hub-signs-in-its-spec.md AC-5, AC-7, AC-8).
|
|
8
|
+
*
|
|
9
|
+
* Covers the sealed cookie (a value sealed under a key derived from the
|
|
10
|
+
* CLIENT secret never opens — AC-5), the configuration, the session read, the
|
|
11
|
+
* one request-init re-target `forwardedCookieInit()` makes on a hub-mode spoke
|
|
12
|
+
* (AC-7), and the buyer read that never trusts the cookie for a customer id
|
|
13
|
+
* (AC-8). The api-client's `hubSignIn` is the one external boundary mocked.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createCipheriv, hkdfSync, randomBytes } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
const mockMe = jest.fn();
|
|
19
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
20
|
+
__esModule: true,
|
|
21
|
+
hubSignIn: { me: (...args: unknown[]) => mockMe(...args) },
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const mockHeaders = jest.fn();
|
|
25
|
+
jest.mock("next/headers", () => ({
|
|
26
|
+
__esModule: true,
|
|
27
|
+
headers: (...args: unknown[]) => mockHeaders(...args),
|
|
28
|
+
cookies: jest.fn(),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// 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.
|
|
32
|
+
jest.mock("../../topology.generated", () => ({
|
|
33
|
+
__esModule: true,
|
|
34
|
+
SIGN_IN_PROVIDER: "hub",
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
import { forwardedCookieInit } from "../../server-api";
|
|
38
|
+
import { hubSessionConfig } from "../config";
|
|
39
|
+
import { seal, unseal } from "../seal";
|
|
40
|
+
import {
|
|
41
|
+
type HubSessionPayload,
|
|
42
|
+
hubNextPath,
|
|
43
|
+
hubSessionBuyer,
|
|
44
|
+
hubSessionInit,
|
|
45
|
+
readHubSession,
|
|
46
|
+
sessionCookieName,
|
|
47
|
+
} from "../session";
|
|
48
|
+
|
|
49
|
+
const ENV = process.env;
|
|
50
|
+
const SESSION_SECRET = "s".repeat(43);
|
|
51
|
+
const CLIENT_SECRET = `ailk_hcs_${"c".repeat(43)}`;
|
|
52
|
+
const CREDENTIAL = `ailk_hst_${"t".repeat(43)}`;
|
|
53
|
+
const COOKIE = "ailk-hub-session";
|
|
54
|
+
|
|
55
|
+
const USER = {
|
|
56
|
+
name: "Ada Lovelace",
|
|
57
|
+
email: "ada@example.com",
|
|
58
|
+
emailVerified: true,
|
|
59
|
+
createdAt: "2026-09-01T00:00:00.000Z",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function configure(overrides: Record<string, string | undefined> = {}): void {
|
|
63
|
+
process.env = {
|
|
64
|
+
...ENV,
|
|
65
|
+
AILK_HUB_SIGN_IN_URL: "https://hub.example.com",
|
|
66
|
+
AILK_HUB_CLIENT_ID: "spoke_0001",
|
|
67
|
+
AILK_HUB_CLIENT_SECRET: CLIENT_SECRET,
|
|
68
|
+
AILK_HUB_SESSION_SECRET: SESSION_SECRET,
|
|
69
|
+
NEXT_PUBLIC_BASE_URL: "http://127.0.0.1:3100",
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
73
|
+
if (value === undefined) delete process.env[key];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function payload(overrides: Partial<HubSessionPayload> = {}): HubSessionPayload {
|
|
78
|
+
return {
|
|
79
|
+
v: 1,
|
|
80
|
+
accessToken: CREDENTIAL,
|
|
81
|
+
expiresAt: Date.now() + 60_000,
|
|
82
|
+
user: USER,
|
|
83
|
+
...overrides,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function headersWith(value: string, name = COOKIE): Headers {
|
|
88
|
+
return new Headers({ cookie: `other=1; ${name}=${value}` });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
configure();
|
|
93
|
+
mockMe.mockReset();
|
|
94
|
+
mockHeaders.mockReset();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterAll(() => {
|
|
98
|
+
process.env = ENV;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("seal", () => {
|
|
102
|
+
it("round-trips a value for the same secret, purpose and cookie name", () => {
|
|
103
|
+
const sealed = seal({ a: 1 }, SESSION_SECRET, "session", COOKIE);
|
|
104
|
+
expect(unseal(sealed, SESSION_SECRET, "session", COOKIE)).toEqual({ a: 1 });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("opens nothing under another secret, purpose or cookie name, or altered", () => {
|
|
108
|
+
const sealed = seal({ a: 1 }, SESSION_SECRET, "session", COOKIE);
|
|
109
|
+
expect(unseal(sealed, "x".repeat(43), "session", COOKIE)).toBeNull();
|
|
110
|
+
expect(unseal(sealed, SESSION_SECRET, "flow", COOKIE)).toBeNull();
|
|
111
|
+
expect(unseal(sealed, SESSION_SECRET, "session", "ailk-hub-flow")).toBeNull();
|
|
112
|
+
const flipped = Buffer.from(sealed, "base64url");
|
|
113
|
+
flipped[flipped.length - 1] = (flipped[flipped.length - 1] ?? 0) ^ 1;
|
|
114
|
+
expect(unseal(flipped.toString("base64url"), SESSION_SECRET, "session", COOKIE)).toBeNull();
|
|
115
|
+
expect(unseal("", SESSION_SECRET, "session", COOKIE)).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("uses a fresh IV per seal", () => {
|
|
119
|
+
expect(seal({ a: 1 }, SESSION_SECRET, "session", COOKIE)).not.toBe(
|
|
120
|
+
seal({ a: 1 }, SESSION_SECRET, "session", COOKIE),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("hubSessionConfig", () => {
|
|
126
|
+
it("reads the four variables and the site origin; the redirect URI is the site's, never a header's", () => {
|
|
127
|
+
expect(hubSessionConfig()).toEqual({
|
|
128
|
+
hubOrigin: "https://hub.example.com",
|
|
129
|
+
clientId: "spoke_0001",
|
|
130
|
+
clientSecret: CLIENT_SECRET,
|
|
131
|
+
sessionSecret: SESSION_SECRET,
|
|
132
|
+
siteOrigin: "http://127.0.0.1:3100",
|
|
133
|
+
redirectUri: "http://127.0.0.1:3100/api/hub-session/callback",
|
|
134
|
+
secure: false,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("is secure on an https site", () => {
|
|
139
|
+
configure({ NEXT_PUBLIC_BASE_URL: "https://spoke.example.com/" });
|
|
140
|
+
expect(hubSessionConfig()).toMatchObject({
|
|
141
|
+
siteOrigin: "https://spoke.example.com",
|
|
142
|
+
secure: true,
|
|
143
|
+
});
|
|
144
|
+
expect(sessionCookieName({ secure: true })).toBe("__Host-ailk-hub-session");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it.each([
|
|
148
|
+
["no hub origin", { AILK_HUB_SIGN_IN_URL: undefined }],
|
|
149
|
+
["an http hub off loopback", { AILK_HUB_SIGN_IN_URL: "http://hub.example.com" }],
|
|
150
|
+
["a hub URL with a path", { AILK_HUB_SIGN_IN_URL: "https://hub.example.com/x" }],
|
|
151
|
+
["no client id", { AILK_HUB_CLIENT_ID: undefined }],
|
|
152
|
+
["no client secret", { AILK_HUB_CLIENT_SECRET: undefined }],
|
|
153
|
+
["a short session secret", { AILK_HUB_SESSION_SECRET: "short" }],
|
|
154
|
+
["an http site off loopback", { NEXT_PUBLIC_BASE_URL: "http://spoke.example.com" }],
|
|
155
|
+
])("is null with %s", (_label, overrides) => {
|
|
156
|
+
configure(overrides);
|
|
157
|
+
expect(hubSessionConfig()).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("readHubSession", () => {
|
|
162
|
+
it("reads the person from a sealed, unexpired cookie", async () => {
|
|
163
|
+
const sealed = seal(payload(), SESSION_SECRET, "session", COOKIE);
|
|
164
|
+
await expect(readHubSession(headersWith(sealed))).resolves.toEqual({ user: USER });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("reads null with no cookie, an expired one, or with no configuration", async () => {
|
|
168
|
+
await expect(readHubSession(new Headers())).resolves.toBeNull();
|
|
169
|
+
const expired = seal(payload({ expiresAt: Date.now() - 1 }), SESSION_SECRET, "session", COOKIE);
|
|
170
|
+
await expect(readHubSession(headersWith(expired))).resolves.toBeNull();
|
|
171
|
+
const sealed = seal(payload(), SESSION_SECRET, "session", COOKIE);
|
|
172
|
+
configure({ AILK_HUB_SESSION_SECRET: undefined });
|
|
173
|
+
await expect(readHubSession(headersWith(sealed))).resolves.toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("AC-5: a cookie sealed with a key derived from AILK_HUB_CLIENT_SECRET reads as null", async () => {
|
|
177
|
+
// Every way the client secret could have keyed the cookie: the module's own
|
|
178
|
+
// seal with the client secret as the session secret, and a raw HKDF of it.
|
|
179
|
+
const viaSeal = seal(payload(), CLIENT_SECRET, "session", COOKIE);
|
|
180
|
+
await expect(readHubSession(headersWith(viaSeal))).resolves.toBeNull();
|
|
181
|
+
|
|
182
|
+
const key = Buffer.from(hkdfSync("sha256", CLIENT_SECRET, Buffer.alloc(0), "ailk-hub-session/v1", 32));
|
|
183
|
+
const iv = randomBytes(12);
|
|
184
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
185
|
+
cipher.setAAD(Buffer.from(COOKIE));
|
|
186
|
+
const body = Buffer.concat([cipher.update(JSON.stringify(payload())), cipher.final()]);
|
|
187
|
+
const forged = Buffer.concat([iv, cipher.getAuthTag(), body]).toString("base64url");
|
|
188
|
+
await expect(readHubSession(headersWith(forged))).resolves.toBeNull();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("reads null for a well-sealed value of the wrong shape", async () => {
|
|
192
|
+
const sealed = seal({ v: 2, accessToken: CREDENTIAL }, SESSION_SECRET, "session", COOKIE);
|
|
193
|
+
await expect(readHubSession(headersWith(sealed))).resolves.toBeNull();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("AC-7 — forwardedCookieInit on a hub-mode spoke", () => {
|
|
198
|
+
it("is exactly the Bearer credential signed in", async () => {
|
|
199
|
+
const sealed = seal(payload(), SESSION_SECRET, "session", COOKIE);
|
|
200
|
+
mockHeaders.mockResolvedValue(headersWith(sealed));
|
|
201
|
+
await expect(forwardedCookieInit()).resolves.toEqual({
|
|
202
|
+
headers: { authorization: `Bearer ${CREDENTIAL}` },
|
|
203
|
+
});
|
|
204
|
+
await expect(hubSessionInit()).resolves.toEqual({
|
|
205
|
+
headers: { authorization: `Bearer ${CREDENTIAL}` },
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("is exactly no header signed out — never the visitor's cookie", async () => {
|
|
210
|
+
mockHeaders.mockResolvedValue(new Headers({ cookie: "better-auth.session_token=abc" }));
|
|
211
|
+
await expect(forwardedCookieInit()).resolves.toEqual({ headers: {} });
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("AC-8 — hubSessionBuyer reads the customer live, never from the cookie", () => {
|
|
216
|
+
function request(cookie?: string): Request {
|
|
217
|
+
return new Request("http://127.0.0.1:3100/api/buy", {
|
|
218
|
+
headers: cookie === undefined ? {} : { cookie: `${COOKIE}=${cookie}` },
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const signedIn = (): Request => request(seal(payload(), SESSION_SECRET, "session", COOKIE));
|
|
222
|
+
|
|
223
|
+
it("returns the customer /me vouches for, sending the credential", async () => {
|
|
224
|
+
mockMe.mockResolvedValue({ ok: true, body: { user: USER, billingCustomerId: "cus_known" } });
|
|
225
|
+
await expect(hubSessionBuyer(signedIn())).resolves.toEqual({ customerId: "cus_known" });
|
|
226
|
+
expect(mockMe).toHaveBeenCalledWith({ headers: { authorization: `Bearer ${CREDENTIAL}` } });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("is null with no session, without calling the hub", async () => {
|
|
230
|
+
await expect(hubSessionBuyer(request())).resolves.toBeNull();
|
|
231
|
+
expect(mockMe).not.toHaveBeenCalled();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("is null when /me refuses the credential though the cookie is intact (a revoked credential)", async () => {
|
|
235
|
+
mockMe.mockResolvedValue({ ok: false, status: 401, body: null });
|
|
236
|
+
await expect(hubSessionBuyer(signedIn())).resolves.toBeNull();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("is null when /me names no customer, or the read throws", async () => {
|
|
240
|
+
mockMe.mockResolvedValueOnce({ ok: true, body: { user: USER, billingCustomerId: null } });
|
|
241
|
+
await expect(hubSessionBuyer(signedIn())).resolves.toBeNull();
|
|
242
|
+
mockMe.mockRejectedValueOnce(new Error("network"));
|
|
243
|
+
await expect(hubSessionBuyer(signedIn())).resolves.toBeNull();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("hubNextPath", () => {
|
|
248
|
+
it.each([
|
|
249
|
+
["/account", "/account"],
|
|
250
|
+
["/fr/account/inbox?tab=x", "/fr/account/inbox?tab=x"],
|
|
251
|
+
["https://evil.example.com/", "/account"],
|
|
252
|
+
["//evil.example.com", "/account"],
|
|
253
|
+
["/api/hub-session/sign-out", "/account"],
|
|
254
|
+
["/api", "/account"],
|
|
255
|
+
["/en/../api/hub-session/sign-in?next=/account", "/account"],
|
|
256
|
+
["/%2e%2e/api/hub-session/sign-in", "/account"],
|
|
257
|
+
["/\u65e5\u672c", "/account"],
|
|
258
|
+
[null, "/account"],
|
|
259
|
+
])("%s → %s", (raw, expected) => {
|
|
260
|
+
expect(hubNextPath(raw)).toBe(expected);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The spoke's hub-session configuration (#7894 D7) — sign-in through the hub.
|
|
3
|
+
*
|
|
4
|
+
* A spoke scaffolded with `--hub-sign-in <origin>` signs a visitor in on its
|
|
5
|
+
* hub and keeps the hub-issued credential in a sealed cookie. Everything that
|
|
6
|
+
* decides where that exchange goes is read here, from the server env, once
|
|
7
|
+
* per call:
|
|
8
|
+
*
|
|
9
|
+
* - `AILK_HUB_SIGN_IN_URL` — the hub's WEB origin (where `/sign-in` and
|
|
10
|
+
* `/api/auth/hub/authorize` live);
|
|
11
|
+
* - `AILK_HUB_CLIENT_ID` — this spoke's client id (its `Spoke.id`);
|
|
12
|
+
* - `AILK_HUB_CLIENT_SECRET` — the secret `spokes sign-in-client` printed;
|
|
13
|
+
* - `AILK_HUB_SESSION_SECRET` — this site's own cookie key material (at
|
|
14
|
+
* least 32 characters). It never derives from
|
|
15
|
+
* the client secret (`seal.ts`).
|
|
16
|
+
*
|
|
17
|
+
* The site's own origin is `resolveBaseUrl()` — `NEXT_PUBLIC_BASE_URL`, else
|
|
18
|
+
* the site's `baseUrl` — never a request header, so a forged `Host` cannot move
|
|
19
|
+
* the redirect URI. Any input missing or malformed reads as "not configured"
|
|
20
|
+
* (`null`): the routes then send the visitor back to `/account` and the gate
|
|
21
|
+
* says sign-in is not set up, rather than starting a flow that cannot finish.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { resolveBaseUrl } from "../../src/lib/routes";
|
|
25
|
+
|
|
26
|
+
/** The path the hub redirects back to — the one an operator registers (D3). */
|
|
27
|
+
export const HUB_SESSION_CALLBACK_PATH = "/api/hub-session/callback";
|
|
28
|
+
|
|
29
|
+
/** Hosts an origin may use over plain `http:` (the api-client's loopback set). */
|
|
30
|
+
const LOOPBACK_HOSTS: readonly string[] = ["localhost", "127.0.0.1"];
|
|
31
|
+
|
|
32
|
+
/** The shortest `AILK_HUB_SESSION_SECRET` this module accepts. */
|
|
33
|
+
const MIN_SESSION_SECRET_LENGTH = 32;
|
|
34
|
+
|
|
35
|
+
export interface HubSessionConfig {
|
|
36
|
+
/** The hub's web origin, normalized (no trailing slash). */
|
|
37
|
+
hubOrigin: string;
|
|
38
|
+
clientId: string;
|
|
39
|
+
clientSecret: string;
|
|
40
|
+
sessionSecret: string;
|
|
41
|
+
/** This site's own origin. */
|
|
42
|
+
siteOrigin: string;
|
|
43
|
+
/** `<siteOrigin>/api/hub-session/callback`. */
|
|
44
|
+
redirectUri: string;
|
|
45
|
+
/** Whether the site origin is https — the cookies are then `__Host-` and `Secure`. */
|
|
46
|
+
secure: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `raw` as an https origin (or http on a loopback host), else `null`. With
|
|
51
|
+
* `originOnly`, a path other than `/`, a query, a fragment or userinfo is
|
|
52
|
+
* refused rather than dropped.
|
|
53
|
+
*/
|
|
54
|
+
function normalizeOrigin(
|
|
55
|
+
raw: string | undefined,
|
|
56
|
+
originOnly: boolean,
|
|
57
|
+
): string | null {
|
|
58
|
+
if (raw === undefined || raw.trim() === "") return null;
|
|
59
|
+
let url: URL;
|
|
60
|
+
try {
|
|
61
|
+
url = new URL(raw.trim());
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const loopback = LOOPBACK_HOSTS.includes(url.hostname);
|
|
66
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (url.username !== "" || url.password !== "") return null;
|
|
70
|
+
if (
|
|
71
|
+
originOnly &&
|
|
72
|
+
(url.pathname !== "/" || url.search !== "" || url.hash !== "")
|
|
73
|
+
) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return url.origin;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The hub-session configuration, or `null` when this site is not configured. */
|
|
80
|
+
export function hubSessionConfig(): HubSessionConfig | null {
|
|
81
|
+
const hubOrigin = normalizeOrigin(process.env.AILK_HUB_SIGN_IN_URL, true);
|
|
82
|
+
const clientId = process.env.AILK_HUB_CLIENT_ID?.trim() ?? "";
|
|
83
|
+
const clientSecret = process.env.AILK_HUB_CLIENT_SECRET?.trim() ?? "";
|
|
84
|
+
const sessionSecret = process.env.AILK_HUB_SESSION_SECRET?.trim() ?? "";
|
|
85
|
+
const siteOrigin = normalizeOrigin(resolveBaseUrl(), false);
|
|
86
|
+
if (
|
|
87
|
+
hubOrigin === null ||
|
|
88
|
+
siteOrigin === null ||
|
|
89
|
+
clientId === "" ||
|
|
90
|
+
clientSecret === "" ||
|
|
91
|
+
sessionSecret.length < MIN_SESSION_SECRET_LENGTH
|
|
92
|
+
) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
hubOrigin,
|
|
97
|
+
clientId,
|
|
98
|
+
clientSecret,
|
|
99
|
+
sessionSecret,
|
|
100
|
+
siteOrigin,
|
|
101
|
+
redirectUri: `${siteOrigin}${HUB_SESSION_CALLBACK_PATH}`,
|
|
102
|
+
secure: siteOrigin.startsWith("https:"),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The spoke's hub-client module (#7894 D7) — sign-in through the hub, with no
|
|
3
|
+
* database. It imports no spoke removal seed and no `(auth)` file, so a spoke
|
|
4
|
+
* keeps it (create-ailk's closure guard proves that, spec 7894 D9).
|
|
5
|
+
*
|
|
6
|
+
* The signed-out gate (`sign-in-gate.tsx`) is imported by its own path from
|
|
7
|
+
* the generated gate seam, so the server-side readers below never pull a
|
|
8
|
+
* React tree into the modules that call them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
HUB_SESSION_CALLBACK_PATH,
|
|
13
|
+
type HubSessionConfig,
|
|
14
|
+
hubSessionConfig,
|
|
15
|
+
} from "./config";
|
|
16
|
+
export {
|
|
17
|
+
type HubSessionPayload,
|
|
18
|
+
type HubSessionUser,
|
|
19
|
+
hubNextPath,
|
|
20
|
+
hubSessionBuyer,
|
|
21
|
+
hubSessionInit,
|
|
22
|
+
readHubSession,
|
|
23
|
+
} from "./session";
|