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,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file person.ts
|
|
3
|
+
* @description Person — one client of one workspace (#852, spec
|
|
4
|
+
* docs/specs/852-person-the-per-workspace-client-spec.md D3, D5, D6). This
|
|
5
|
+
* file is the ONLY writer of `Person`:
|
|
6
|
+
*
|
|
7
|
+
* - `findOrCreatePerson` — the one creator, called by lead capture
|
|
8
|
+
* (`lead-routing.ts`), the hub's own Stripe checkout webhook (through
|
|
9
|
+
* `recordCheckoutPerson` below), appointment creation
|
|
10
|
+
* (`scheduling/appointments.ts`) and the Admin inbox accept
|
|
11
|
+
* (`inbox-messages.ts`).
|
|
12
|
+
* - `stampPersonUser` — links a Person to a User, only on a verified-email
|
|
13
|
+
* match, never overwriting a link.
|
|
14
|
+
* - `recordCheckoutPerson` — the webhook's step: the Person of a completed
|
|
15
|
+
* hub checkout, with its live-mode customer id.
|
|
16
|
+
* - `resolveMemberPersonId` — the member reads' Person (#7877 D8): the one
|
|
17
|
+
* linked to the caller, else the one whose email is the caller's
|
|
18
|
+
* verified email, linked on first read by `stampPersonUser`'s rule.
|
|
19
|
+
* - `resolveCallerBillingCustomerId` — the customer id the hub vouches for
|
|
20
|
+
* in `GET /v1/hub-sign-in/me` (#7961, spec 7894 D13): that same Person's,
|
|
21
|
+
* only while its `userId` is the caller.
|
|
22
|
+
* - `setPersonPlanOffering` — the plan's only writer (#7877 D8), called by
|
|
23
|
+
* the Admin's plan route and the checkout-lead rule in `lead-routing.ts`.
|
|
24
|
+
*
|
|
25
|
+
* ## Match order (D3) — fixed, and there is no merge
|
|
26
|
+
*
|
|
27
|
+
* 1. Normalize the email (trim ASCII whitespace, lowercase ASCII letters);
|
|
28
|
+
* not storable → `invalid_email`, before any read.
|
|
29
|
+
* 2. A customer id given → the oldest Person of this workspace carrying it.
|
|
30
|
+
* A hit is returned with NO write — its email is never rewritten.
|
|
31
|
+
* 3. The Person of this workspace with this email. A hit gains the customer
|
|
32
|
+
* id only when it has none AND is linked to no user (first writer wins,
|
|
33
|
+
* else `billing: "kept"`), and a name only when it has none. The email is
|
|
34
|
+
* whatever the caller typed, never verified here, so it never pins a
|
|
35
|
+
* customer on a Person whose `userId` the verified-email stamp set
|
|
36
|
+
* (SEC-7894-6, decided on #7961 2026-10-09).
|
|
37
|
+
* 4. No hit → `createMany({ skipDuplicates })` — Postgres INSERT … ON
|
|
38
|
+
* CONFLICT DO NOTHING — then a re-read that finds the winner of any
|
|
39
|
+
* concurrent create. The duplicate is absorbed IN THE DATABASE, never as
|
|
40
|
+
* a caught P2002: the appointment path calls this inside an interactive
|
|
41
|
+
* transaction, where a raised unique violation aborts the transaction
|
|
42
|
+
* (C4). One implementation serves both.
|
|
43
|
+
*
|
|
44
|
+
* ## Why public, and what it may import (D3)
|
|
45
|
+
*
|
|
46
|
+
* The base-tree `lead-routing.ts` imports this file, so it ships in every
|
|
47
|
+
* build and must never reach the paid import closure: it imports
|
|
48
|
+
* `lib/prisma.js`, `verified-email.js` and types only — never
|
|
49
|
+
* `lead-promotion.ts`, `owner-workspaces.ts`, `lead-accept-user.ts` (whose
|
|
50
|
+
* find-or-create shape it mirrors) or `lib/tenant-db.ts`.
|
|
51
|
+
* `packages/create-ailk/__tests__/paid-module-exclusions.test.ts` holds this
|
|
52
|
+
* file and `lead-routing.ts` in `MUST_SHIP`.
|
|
53
|
+
*
|
|
54
|
+
* ## Tenancy
|
|
55
|
+
*
|
|
56
|
+
* Every `Person` statement is scoped by a server-resolved `workspaceId`;
|
|
57
|
+
* there is no list read here (the reads are `tenantDb(tenant).people`).
|
|
58
|
+
* `recordCheckoutPerson`'s two other reads are by a server-minted
|
|
59
|
+
* `FlowCheckout` id from a signature-verified webhook, matched on that
|
|
60
|
+
* session's own `stripeSessionId`, then that row's lead scoped by the row's
|
|
61
|
+
* own `workspaceId`. `resolveMemberPersonId` reads by `(workspaceId, userId)`
|
|
62
|
+
* and by `(workspaceId, email)`, and `resolveCallerBillingCustomerId` by
|
|
63
|
+
* `(id, workspaceId, userId)`, the workspace server-resolved and the user
|
|
64
|
+
* the authenticated caller. `architecture.yaml` names this file as an
|
|
65
|
+
* exclusion with that reason.
|
|
66
|
+
*
|
|
67
|
+
* ## Errors and logs
|
|
68
|
+
*
|
|
69
|
+
* A store error is caught and returned as a bare kind; no Prisma message is
|
|
70
|
+
* returned or logged (SEC-006). These functions log nothing: each caller
|
|
71
|
+
* logs the outcome kind, never an email, a name or a customer id. The
|
|
72
|
+
* exceptions are `resolveMemberPersonId`, whose `null` hides a refused stamp,
|
|
73
|
+
* and `resolveCallerBillingCustomerId`, whose `null` hides a failed read: each
|
|
74
|
+
* logs that outcome's kind through the logger its caller passes.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
import type { Prisma } from "@working-theory/database";
|
|
78
|
+
import type { Id } from "@working-theory/ids";
|
|
79
|
+
|
|
80
|
+
import { prisma } from "../lib/prisma.js";
|
|
81
|
+
|
|
82
|
+
import { findVerifiedEmail } from "./verified-email.js";
|
|
83
|
+
|
|
84
|
+
// ─── Normalization ────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/** The ASCII whitespace the backfill's btrim set names: SP, HT, LF, CR, FF, VT. */
|
|
87
|
+
const EDGE_WHITESPACE = /^[ \t\n\r\f\v]+|[ \t\n\r\f\v]+$/g;
|
|
88
|
+
/** Printable ASCII, one `@`, a non-empty side each — the `people_email_normalized` CHECK. */
|
|
89
|
+
const STORABLE_EMAIL = /^[!-?A-~]+@[!-?A-~]+$/;
|
|
90
|
+
const MAX_EMAIL_LENGTH = 254;
|
|
91
|
+
const MAX_NAME_CODE_POINTS = 200;
|
|
92
|
+
const MAX_CUSTOMER_ID_LENGTH = 255;
|
|
93
|
+
/** C0 and C1 controls and the bidi controls, removed from a name (SEC-852-I). */
|
|
94
|
+
// eslint-disable-next-line no-control-regex -- the control-character range is exactly what a stored name must not carry.
|
|
95
|
+
const NAME_STRIP = /[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Trim ASCII whitespace, then lowercase ASCII letters only — the same value
|
|
99
|
+
* Postgres computes as `lower(btrim(x, E' \t\n\r\f\x0B') COLLATE "C")`.
|
|
100
|
+
*/
|
|
101
|
+
export function normalizePersonEmail(raw: string): string {
|
|
102
|
+
return raw
|
|
103
|
+
.replace(EDGE_WHITESPACE, "")
|
|
104
|
+
.replace(/[A-Z]/g, (c) => c.toLowerCase());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Printable ASCII, one `@` with a non-empty side each, at most 254
|
|
109
|
+
* characters — the `people_email_normalized` CHECK's regex. ASCII only, so
|
|
110
|
+
* the TypeScript length and the Postgres length agree.
|
|
111
|
+
*/
|
|
112
|
+
export function isStorablePersonEmail(email: string): boolean {
|
|
113
|
+
return email.length <= MAX_EMAIL_LENGTH && STORABLE_EMAIL.test(email);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A name as stored: controls and bidi controls removed, trimmed of spaces, cut
|
|
118
|
+
* to 200 code points; empty → `null`.
|
|
119
|
+
*/
|
|
120
|
+
export function normalizePersonName(raw: unknown): string | null {
|
|
121
|
+
if (typeof raw !== "string") return null;
|
|
122
|
+
// Spaces only, as the backfill's `btrim(x)` trims, so a live-written
|
|
123
|
+
// name and a backfilled one agree.
|
|
124
|
+
const cleaned = Array.from(raw.replace(NAME_STRIP, "").replace(/^ +| +$/g, ""))
|
|
125
|
+
.slice(0, MAX_NAME_CODE_POINTS)
|
|
126
|
+
.join("");
|
|
127
|
+
return cleaned === "" ? null : cleaned;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function usableCustomerId(raw: string | null | undefined): string | null {
|
|
131
|
+
return typeof raw === "string" &&
|
|
132
|
+
raw.length > 0 &&
|
|
133
|
+
raw.length <= MAX_CUSTOMER_ID_LENGTH
|
|
134
|
+
? raw
|
|
135
|
+
: null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── findOrCreatePerson ───────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/** The one delegate this file writes through — the shared client or a transaction's. */
|
|
141
|
+
export type PersonDb = Pick<Prisma.TransactionClient, "person">;
|
|
142
|
+
|
|
143
|
+
export type FindOrCreatePersonResult =
|
|
144
|
+
| {
|
|
145
|
+
ok: true;
|
|
146
|
+
value: {
|
|
147
|
+
personId: Id<"person">;
|
|
148
|
+
created: boolean;
|
|
149
|
+
billing: "written" | "kept" | "unchanged";
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
| { ok: false; error: { kind: "invalid_email" | "internal_error" } };
|
|
153
|
+
|
|
154
|
+
type MatchedPerson = {
|
|
155
|
+
id: string;
|
|
156
|
+
name: string | null;
|
|
157
|
+
userId: string | null;
|
|
158
|
+
billingCustomerId: string | null;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const MATCH_SELECT = {
|
|
162
|
+
id: true,
|
|
163
|
+
name: true,
|
|
164
|
+
userId: true,
|
|
165
|
+
billingCustomerId: true,
|
|
166
|
+
} as const;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Step 3's writes on an email match: the customer id only when the Person
|
|
170
|
+
* has none and is linked to no user, the name only when it has none — each
|
|
171
|
+
* one guarded `updateMany`, so a concurrent writer's value, or a stamp that
|
|
172
|
+
* lands between the read and the write, is never overwritten or bypassed.
|
|
173
|
+
*/
|
|
174
|
+
async function enrichMatched(
|
|
175
|
+
db: PersonDb,
|
|
176
|
+
workspaceId: string,
|
|
177
|
+
person: MatchedPerson,
|
|
178
|
+
name: string | null,
|
|
179
|
+
customerId: string | null,
|
|
180
|
+
): Promise<"written" | "kept" | "unchanged"> {
|
|
181
|
+
let billing: "written" | "kept" | "unchanged" = "unchanged";
|
|
182
|
+
if (customerId !== null) {
|
|
183
|
+
if (person.billingCustomerId !== null || person.userId !== null) {
|
|
184
|
+
billing = "kept";
|
|
185
|
+
} else {
|
|
186
|
+
const written = await db.person.updateMany({
|
|
187
|
+
where: { id: person.id, workspaceId, userId: null, billingCustomerId: null },
|
|
188
|
+
data: { billingProvider: "stripe", billingCustomerId: customerId },
|
|
189
|
+
});
|
|
190
|
+
billing = written.count === 1 ? "written" : "kept";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (name !== null && person.name === null) {
|
|
194
|
+
await db.person.updateMany({
|
|
195
|
+
where: { id: person.id, workspaceId, name: null },
|
|
196
|
+
data: { name },
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return billing;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Find this workspace's Person for an email, or create it.
|
|
204
|
+
*
|
|
205
|
+
* @param workspaceId the SERVER-RESOLVED workspace id — never a request value.
|
|
206
|
+
* @param input.email the raw address; normalized here.
|
|
207
|
+
* @param input.name an optional display name, from anonymous text.
|
|
208
|
+
* @param input.billingCustomerId an optional provider customer id.
|
|
209
|
+
* @param db the shared client by default; the appointment path passes its
|
|
210
|
+
* transaction client so the step runs on the lock's own connection.
|
|
211
|
+
*/
|
|
212
|
+
export async function findOrCreatePerson(
|
|
213
|
+
workspaceId: string,
|
|
214
|
+
input: {
|
|
215
|
+
email: string;
|
|
216
|
+
name?: string | null;
|
|
217
|
+
billingCustomerId?: string | null;
|
|
218
|
+
},
|
|
219
|
+
db: PersonDb = prisma,
|
|
220
|
+
): Promise<FindOrCreatePersonResult> {
|
|
221
|
+
const email =
|
|
222
|
+
typeof input.email === "string" ? normalizePersonEmail(input.email) : "";
|
|
223
|
+
if (!isStorablePersonEmail(email)) {
|
|
224
|
+
return { ok: false, error: { kind: "invalid_email" } };
|
|
225
|
+
}
|
|
226
|
+
const name = normalizePersonName(input.name);
|
|
227
|
+
const customerId = usableCustomerId(input.billingCustomerId);
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
// Step 2 — a customer id names its Person; nothing is written.
|
|
231
|
+
if (customerId !== null) {
|
|
232
|
+
const byCustomer = await db.person.findFirst({
|
|
233
|
+
where: { workspaceId, billingCustomerId: customerId },
|
|
234
|
+
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
|
235
|
+
select: { id: true },
|
|
236
|
+
});
|
|
237
|
+
if (byCustomer) {
|
|
238
|
+
return {
|
|
239
|
+
ok: true,
|
|
240
|
+
value: {
|
|
241
|
+
personId: byCustomer.id as Id<"person">,
|
|
242
|
+
created: false,
|
|
243
|
+
billing: "unchanged",
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Step 3 — the email's Person.
|
|
250
|
+
const byEmail = await db.person.findFirst({
|
|
251
|
+
where: { workspaceId, email },
|
|
252
|
+
select: MATCH_SELECT,
|
|
253
|
+
});
|
|
254
|
+
if (byEmail) {
|
|
255
|
+
const billing = await enrichMatched(db, workspaceId, byEmail, name, customerId);
|
|
256
|
+
return {
|
|
257
|
+
ok: true,
|
|
258
|
+
value: { personId: byEmail.id as Id<"person">, created: false, billing },
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Step 4 — insert, absorbing a concurrent duplicate in the database.
|
|
263
|
+
const inserted = await db.person.createMany({
|
|
264
|
+
data: [
|
|
265
|
+
{
|
|
266
|
+
workspaceId,
|
|
267
|
+
email,
|
|
268
|
+
name,
|
|
269
|
+
...(customerId !== null && {
|
|
270
|
+
billingProvider: "stripe",
|
|
271
|
+
billingCustomerId: customerId,
|
|
272
|
+
}),
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
skipDuplicates: true,
|
|
276
|
+
});
|
|
277
|
+
const winner = await db.person.findFirst({
|
|
278
|
+
where: { workspaceId, email },
|
|
279
|
+
select: MATCH_SELECT,
|
|
280
|
+
});
|
|
281
|
+
if (!winner) return { ok: false, error: { kind: "internal_error" } };
|
|
282
|
+
if (inserted.count === 1) {
|
|
283
|
+
return {
|
|
284
|
+
ok: true,
|
|
285
|
+
value: {
|
|
286
|
+
personId: winner.id as Id<"person">,
|
|
287
|
+
created: true,
|
|
288
|
+
billing: customerId !== null ? "written" : "unchanged",
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
// A concurrent create won the race: treat its row as an email match.
|
|
293
|
+
const billing = await enrichMatched(db, workspaceId, winner, name, customerId);
|
|
294
|
+
return {
|
|
295
|
+
ok: true,
|
|
296
|
+
value: { personId: winner.id as Id<"person">, created: false, billing },
|
|
297
|
+
};
|
|
298
|
+
} catch {
|
|
299
|
+
return { ok: false, error: { kind: "internal_error" } };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ─── stampPersonUser (D5) ─────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
export type StampPersonUserOutcome =
|
|
306
|
+
| "stamped"
|
|
307
|
+
| "already_linked"
|
|
308
|
+
| "user_unverified"
|
|
309
|
+
| "email_mismatch"
|
|
310
|
+
| "linked_to_other_user"
|
|
311
|
+
| "user_on_other_person"
|
|
312
|
+
| "not_found"
|
|
313
|
+
| "internal_error";
|
|
314
|
+
|
|
315
|
+
function isUniqueViolation(err: unknown): boolean {
|
|
316
|
+
return (
|
|
317
|
+
typeof err === "object" &&
|
|
318
|
+
err !== null &&
|
|
319
|
+
(err as { code?: unknown }).code === "P2002"
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Link a Person to a User — only when the User's VERIFIED email normalizes
|
|
325
|
+
* to the Person's (`verified-email.ts`'s rule), and never over an existing
|
|
326
|
+
* link. Creating a Person never calls this: no verified email is needed to
|
|
327
|
+
* exist. Runs outside any transaction, so catching a P2002 is safe here.
|
|
328
|
+
*/
|
|
329
|
+
export async function stampPersonUser(
|
|
330
|
+
workspaceId: string,
|
|
331
|
+
personId: string,
|
|
332
|
+
userId: string,
|
|
333
|
+
): Promise<StampPersonUserOutcome> {
|
|
334
|
+
try {
|
|
335
|
+
const person = await prisma.person.findFirst({
|
|
336
|
+
where: { id: personId, workspaceId },
|
|
337
|
+
select: { email: true, userId: true },
|
|
338
|
+
});
|
|
339
|
+
if (!person) return "not_found";
|
|
340
|
+
if (person.userId === userId) return "already_linked";
|
|
341
|
+
if (person.userId !== null) return "linked_to_other_user";
|
|
342
|
+
|
|
343
|
+
const verified = await findVerifiedEmail(userId);
|
|
344
|
+
if (!verified.ok) return "user_unverified";
|
|
345
|
+
if (normalizePersonEmail(verified.value.email) !== person.email) {
|
|
346
|
+
return "email_mismatch";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let count: number;
|
|
350
|
+
try {
|
|
351
|
+
({ count } = await prisma.person.updateMany({
|
|
352
|
+
where: { id: personId, workspaceId, userId: null },
|
|
353
|
+
data: { userId },
|
|
354
|
+
}));
|
|
355
|
+
} catch (err) {
|
|
356
|
+
if (isUniqueViolation(err)) return "user_on_other_person";
|
|
357
|
+
throw err;
|
|
358
|
+
}
|
|
359
|
+
if (count === 1) return "stamped";
|
|
360
|
+
|
|
361
|
+
const reread = await prisma.person.findFirst({
|
|
362
|
+
where: { id: personId, workspaceId },
|
|
363
|
+
select: { userId: true },
|
|
364
|
+
});
|
|
365
|
+
if (!reread) return "not_found";
|
|
366
|
+
return reread.userId === userId ? "already_linked" : "linked_to_other_user";
|
|
367
|
+
} catch {
|
|
368
|
+
return "internal_error";
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ─── recordCheckoutPerson (D6) ────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
export type CheckoutPersonOutcome =
|
|
375
|
+
| "person_written"
|
|
376
|
+
| "person_kept"
|
|
377
|
+
| "person_unchanged"
|
|
378
|
+
| "person_skipped"
|
|
379
|
+
| "person_failed";
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The hub webhook's Person step for a paid `checkout.session.completed` of
|
|
383
|
+
* its OWN site carrying `metadata.flowCheckoutId`.
|
|
384
|
+
*
|
|
385
|
+
* Reads the checkout row THIS session completed (`stripeSessionId` must be
|
|
386
|
+
* this session's id and the row `completed`), so a session that merely names
|
|
387
|
+
* another checkout's id matches nothing (SEC-852-H); then that row's lead,
|
|
388
|
+
* scoped by the row's workspace. The customer id's one source is
|
|
389
|
+
* `session.customer`, and it is passed only for a LIVE-mode event: a
|
|
390
|
+
* test-mode checkout typed with someone else's email cannot pin a customer
|
|
391
|
+
* id on their Person (SEC-852-A). Never throws.
|
|
392
|
+
*/
|
|
393
|
+
export async function recordCheckoutPerson(input: {
|
|
394
|
+
flowCheckoutId: string;
|
|
395
|
+
stripeSessionId: string | null | undefined;
|
|
396
|
+
customer: unknown;
|
|
397
|
+
livemode: boolean;
|
|
398
|
+
}): Promise<CheckoutPersonOutcome> {
|
|
399
|
+
if (typeof input.stripeSessionId !== "string" || input.stripeSessionId === "") {
|
|
400
|
+
return "person_skipped";
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
const checkout = await prisma.flowCheckout.findFirst({
|
|
404
|
+
where: {
|
|
405
|
+
id: input.flowCheckoutId,
|
|
406
|
+
stripeSessionId: input.stripeSessionId,
|
|
407
|
+
status: "completed",
|
|
408
|
+
},
|
|
409
|
+
select: { leadId: true, workspaceId: true },
|
|
410
|
+
});
|
|
411
|
+
if (!checkout) return "person_skipped";
|
|
412
|
+
|
|
413
|
+
const lead = await prisma.lead.findFirst({
|
|
414
|
+
where: { id: checkout.leadId, workspaceId: checkout.workspaceId },
|
|
415
|
+
select: { email: true },
|
|
416
|
+
});
|
|
417
|
+
if (!lead) return "person_skipped";
|
|
418
|
+
|
|
419
|
+
const customerId =
|
|
420
|
+
input.livemode === true && typeof input.customer === "string"
|
|
421
|
+
? input.customer
|
|
422
|
+
: undefined;
|
|
423
|
+
const result = await findOrCreatePerson(checkout.workspaceId, {
|
|
424
|
+
email: lead.email,
|
|
425
|
+
...(customerId !== undefined && { billingCustomerId: customerId }),
|
|
426
|
+
});
|
|
427
|
+
if (!result.ok) {
|
|
428
|
+
return result.error.kind === "invalid_email" ? "person_skipped" : "person_failed";
|
|
429
|
+
}
|
|
430
|
+
switch (result.value.billing) {
|
|
431
|
+
case "written":
|
|
432
|
+
return "person_written";
|
|
433
|
+
case "kept":
|
|
434
|
+
return "person_kept";
|
|
435
|
+
default:
|
|
436
|
+
return "person_unchanged";
|
|
437
|
+
}
|
|
438
|
+
} catch {
|
|
439
|
+
return "person_failed";
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ─── The member area (#7877 D8) ───────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The Person a member read answers for: this workspace's Person linked to
|
|
447
|
+
* the caller, else the one whose email is the caller's VERIFIED email —
|
|
448
|
+
* linked now through `stampPersonUser` (its rule, unchanged) and the answer
|
|
449
|
+
* only on `stamped` or `already_linked`. `null` is "not a client here";
|
|
450
|
+
* every other stamp outcome, and a store error, is `null` too, logged by its
|
|
451
|
+
* kind only. Never throws.
|
|
452
|
+
*
|
|
453
|
+
* @param workspaceId the SERVER-RESOLVED workspace (the calling spoke's).
|
|
454
|
+
* @param userId the authenticated caller.
|
|
455
|
+
* @param verifiedEmail the caller's verified address, read by the caller.
|
|
456
|
+
*/
|
|
457
|
+
export async function resolveMemberPersonId(
|
|
458
|
+
workspaceId: string,
|
|
459
|
+
userId: string,
|
|
460
|
+
verifiedEmail: string,
|
|
461
|
+
log?: { warn: (obj: object, msg: string) => void },
|
|
462
|
+
): Promise<string | null> {
|
|
463
|
+
let byEmail: { id: string } | null;
|
|
464
|
+
try {
|
|
465
|
+
const linked = await prisma.person.findFirst({
|
|
466
|
+
where: { workspaceId, userId },
|
|
467
|
+
select: { id: true },
|
|
468
|
+
});
|
|
469
|
+
if (linked) return linked.id;
|
|
470
|
+
|
|
471
|
+
byEmail = await prisma.person.findFirst({
|
|
472
|
+
where: { workspaceId, email: normalizePersonEmail(verifiedEmail) },
|
|
473
|
+
select: { id: true },
|
|
474
|
+
});
|
|
475
|
+
} catch {
|
|
476
|
+
// A store error is "not a client here" for this read: never thrown.
|
|
477
|
+
log?.warn({ event: "member-person-stamp", outcome: "internal_error" }, "[person] member read failed");
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
if (!byEmail) return null;
|
|
481
|
+
|
|
482
|
+
const outcome = await stampPersonUser(workspaceId, byEmail.id, userId);
|
|
483
|
+
if (outcome === "stamped" || outcome === "already_linked") return byEmail.id;
|
|
484
|
+
log?.warn({ event: "member-person-stamp", outcome }, "[person] member link refused");
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* The customer id the hub vouches for to a spoke's buy route (#7961, spec
|
|
490
|
+
* 7894 D13): the `billingCustomerId` of the Person `resolveMemberPersonId`
|
|
491
|
+
* answers for (workspace, caller), read only while that Person's `userId` is
|
|
492
|
+
* the caller — never a Person matched by email alone (SEC-7894-6). `null` is
|
|
493
|
+
* "no customer to reuse"; a store error is `null` too, logged by its kind
|
|
494
|
+
* only. Never throws.
|
|
495
|
+
*
|
|
496
|
+
* @param workspaceId the SERVER-RESOLVED workspace (the calling spoke's).
|
|
497
|
+
* @param userId the authenticated caller.
|
|
498
|
+
* @param verifiedEmail the caller's verified address, read by the caller.
|
|
499
|
+
*/
|
|
500
|
+
export async function resolveCallerBillingCustomerId(
|
|
501
|
+
workspaceId: string,
|
|
502
|
+
userId: string,
|
|
503
|
+
verifiedEmail: string,
|
|
504
|
+
log?: { warn: (obj: object, msg: string) => void },
|
|
505
|
+
): Promise<string | null> {
|
|
506
|
+
const personId = await resolveMemberPersonId(workspaceId, userId, verifiedEmail, log);
|
|
507
|
+
if (personId === null) return null;
|
|
508
|
+
try {
|
|
509
|
+
const person = await prisma.person.findFirst({
|
|
510
|
+
where: { id: personId, workspaceId, userId },
|
|
511
|
+
select: { billingCustomerId: true },
|
|
512
|
+
});
|
|
513
|
+
return person?.billingCustomerId ?? null;
|
|
514
|
+
} catch {
|
|
515
|
+
log?.warn({ event: "buyer-customer-read", outcome: "internal_error" }, "[person] buyer read failed");
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export type SetPlanOfferingOutcome = "set" | "not_found" | "internal_error";
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Set — or, with `null`, clear — a Person's plan: the ONE writer of
|
|
524
|
+
* `Person.planOffering`. One `updateMany` scoped by the server-resolved
|
|
525
|
+
* workspace; no row → `not_found`. Never throws.
|
|
526
|
+
*/
|
|
527
|
+
export async function setPersonPlanOffering(
|
|
528
|
+
workspaceId: string,
|
|
529
|
+
personId: string,
|
|
530
|
+
planOffering: string | null,
|
|
531
|
+
): Promise<SetPlanOfferingOutcome> {
|
|
532
|
+
try {
|
|
533
|
+
const { count } = await prisma.person.updateMany({
|
|
534
|
+
where: { id: personId, workspaceId },
|
|
535
|
+
data: { planOffering },
|
|
536
|
+
});
|
|
537
|
+
return count === 0 ? "not_found" : "set";
|
|
538
|
+
} catch {
|
|
539
|
+
return "internal_error";
|
|
540
|
+
}
|
|
541
|
+
}
|
|
@@ -60,6 +60,15 @@
|
|
|
60
60
|
* NO SECRET IS LOGGED. A refusal reaches `request.log.warn` as `{ reason }`
|
|
61
61
|
* (plus `spokeId` for a matched row) — never the key, its digest or its
|
|
62
62
|
* prefix — and never reaches the wire.
|
|
63
|
+
*
|
|
64
|
+
* THE FLOWS ROUTES' WORKSPACE (#7844 D1). `resolveCallerWorkspace` below is
|
|
65
|
+
* the one function the five `/v1/flows` routes resolve their workspace with:
|
|
66
|
+
* a spoke resolves to its own workspace (`viaSpoke: true`), a keyless call to
|
|
67
|
+
* the `AILK_SITE_ID` alias exactly as before, and anything else to
|
|
68
|
+
* `unresolved` with one of two reasons, `spoke-refused` or `alias-unresolved`.
|
|
69
|
+
* The reasons are for `request.log` only; the routes answer both the same
|
|
70
|
+
* way. It lives here rather than under `routes/flows/` because the two flows
|
|
71
|
+
* route files sit in different module slices, and this file is base tree.
|
|
63
72
|
*/
|
|
64
73
|
import {
|
|
65
74
|
resolvedAdapterKind,
|
|
@@ -232,6 +241,45 @@ export async function resolveCallerSite(
|
|
|
232
241
|
return { kind: "spoke", spokeId: row.id, workspaceId: row.workspaceId };
|
|
233
242
|
}
|
|
234
243
|
|
|
244
|
+
/**
|
|
245
|
+
* The workspace a `/v1/flows` request serves (#7844 D1). `reason` is for
|
|
246
|
+
* `request.log` only; the routes answer both reasons the same way.
|
|
247
|
+
*/
|
|
248
|
+
export type CallerWorkspace =
|
|
249
|
+
| { kind: "resolved"; workspaceId: string; viaSpoke: boolean }
|
|
250
|
+
| { kind: "unresolved"; reason: "spoke-refused" | "alias-unresolved" };
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Resolve the workspace a `/v1/flows` request serves. A spoke resolves to its
|
|
254
|
+
* own workspace; a keyless call to the `AILK_SITE_ID` alias, exactly as the
|
|
255
|
+
* flows routes did before #7844; a refused caller to nothing. There is no
|
|
256
|
+
* fallback: a spoke never reaches the alias workspace (D5).
|
|
257
|
+
*
|
|
258
|
+
* Calls `resolveCallerSite` with no options, so a failed hub-status read on a
|
|
259
|
+
* keyless call rejects (#7844 C1).
|
|
260
|
+
*/
|
|
261
|
+
export async function resolveCallerWorkspace(
|
|
262
|
+
request: FastifyRequest,
|
|
263
|
+
): Promise<CallerWorkspace> {
|
|
264
|
+
const caller = await resolveCallerSite(request);
|
|
265
|
+
if (caller.kind === "refused") {
|
|
266
|
+
return { kind: "unresolved", reason: "spoke-refused" };
|
|
267
|
+
}
|
|
268
|
+
if (caller.kind === "spoke") {
|
|
269
|
+
return {
|
|
270
|
+
kind: "resolved",
|
|
271
|
+
workspaceId: caller.workspaceId,
|
|
272
|
+
viaSpoke: true,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
// Deferred, like every database import in this file.
|
|
276
|
+
const { resolveSiteWorkspaceId } = await import("../lib/site.js");
|
|
277
|
+
const workspaceId = await resolveSiteWorkspaceId();
|
|
278
|
+
return workspaceId === null
|
|
279
|
+
? { kind: "unresolved", reason: "alias-unresolved" }
|
|
280
|
+
: { kind: "resolved", workspaceId, viaSpoke: false };
|
|
281
|
+
}
|
|
282
|
+
|
|
235
283
|
/**
|
|
236
284
|
* The three answers a public read can get. `reason` exists for `request.log`
|
|
237
285
|
* only: the route decides its response from `kind` alone, so no resolution
|
|
@@ -16,12 +16,12 @@ import * as ts from "typescript";
|
|
|
16
16
|
describe("apps/mcp/src/index.ts — bootstrap ordering", () => {
|
|
17
17
|
const src = readFileSync(resolve(__dirname, "../src/index.ts"), "utf-8");
|
|
18
18
|
|
|
19
|
-
it("awaits
|
|
20
|
-
expect(src).toMatch(/await
|
|
19
|
+
it("awaits initPublicWorkspace()", () => {
|
|
20
|
+
expect(src).toMatch(/await initPublicWorkspace\(\)/);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
it("awaits
|
|
24
|
-
const initIdx = src.indexOf("await
|
|
23
|
+
it("awaits initPublicWorkspace() before either transport branch", () => {
|
|
24
|
+
const initIdx = src.indexOf("await initPublicWorkspace()");
|
|
25
25
|
const transportBranchIdx = src.indexOf('if (transport === "http")');
|
|
26
26
|
|
|
27
27
|
expect(initIdx).toBeGreaterThan(-1);
|
|
@@ -31,7 +31,7 @@ describe("apps/mcp/src/index.ts — bootstrap ordering", () => {
|
|
|
31
31
|
|
|
32
32
|
it("wraps the await in try/catch — a rejection (DB down at boot) must not crash the whole server (code review, #5876)", () => {
|
|
33
33
|
const tryIdx = src.indexOf("try {");
|
|
34
|
-
const initIdx = src.indexOf("await
|
|
34
|
+
const initIdx = src.indexOf("await initPublicWorkspace()");
|
|
35
35
|
const catchIdx = src.indexOf("} catch");
|
|
36
36
|
|
|
37
37
|
expect(tryIdx).toBeGreaterThan(-1);
|
|
@@ -25,8 +25,10 @@ import {
|
|
|
25
25
|
initMcpCommerceClaim,
|
|
26
26
|
startMcpClaimRefresh,
|
|
27
27
|
} from "./lib/commerce-claim.js";
|
|
28
|
-
import {
|
|
29
|
-
|
|
28
|
+
import {
|
|
29
|
+
initPublicWorkspace,
|
|
30
|
+
registerLicenseKeyStore,
|
|
31
|
+
} from "./lib/topology.generated.js";
|
|
30
32
|
|
|
31
33
|
// Resolves the MCP server's own workspace ONCE, before either transport
|
|
32
34
|
// branch below — a no-op unless CONTENT_ADAPTER=database (#5876 spec D4/D6).
|
|
@@ -42,10 +44,10 @@ import { initMcpPublicWorkspace } from "./lib/public-workspace.js";
|
|
|
42
44
|
// (D5) — so content tools serve their empty/not-found shape rather than the
|
|
43
45
|
// whole server going dark over one adapter's connectivity.
|
|
44
46
|
try {
|
|
45
|
-
await
|
|
47
|
+
await initPublicWorkspace();
|
|
46
48
|
} catch (err) {
|
|
47
49
|
console.error(
|
|
48
|
-
"[mcp]
|
|
50
|
+
"[mcp] initPublicWorkspace() failed — content tools will serve their " +
|
|
49
51
|
"fail-closed empty shape under CONTENT_ADAPTER=database until this " +
|
|
50
52
|
"resolves; every other tool is unaffected:",
|
|
51
53
|
err,
|
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
emptyContentAdapter,
|
|
44
44
|
readOnlyContentAdapter,
|
|
45
45
|
} from "./empty-content-adapter.js";
|
|
46
|
-
import {
|
|
46
|
+
import { publicWorkspace } from "./topology.generated.js";
|
|
47
47
|
|
|
48
48
|
export interface McpContentAdapterOptions {
|
|
49
49
|
/** Filesystem content root. Defaults to `<cwd>/content` — the tools' prior default. */
|
|
@@ -67,7 +67,7 @@ export function getMcpContentAdapter(
|
|
|
67
67
|
return resolveContentAdapter({ contentRoot, defaultLocale });
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
const workspace =
|
|
70
|
+
const workspace = publicWorkspace();
|
|
71
71
|
if (workspace.kind !== "resolved") {
|
|
72
72
|
return emptyContentAdapter();
|
|
73
73
|
}
|