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,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
|
|
24
|
-
* `@working-theory/ui-signal-collectors` package is absent. The
|
|
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 ??
|
|
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
|
-
|
|
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
|
+
}
|