create-ailk 0.14.0 → 0.15.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/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +34 -2
- package/dist/add-route.js +18 -2
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +23 -3
- package/dist/derive-architecture.d.ts +15 -2
- package/dist/derive-architecture.js +34 -6
- 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/extract-module-bundle.d.ts +11 -0
- package/dist/lib/extract-module-bundle.js +36 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +6 -0
- package/dist/parse-args.d.ts +22 -0
- package/dist/parse-args.js +73 -0
- package/dist/programmatic.d.ts +20 -0
- package/dist/programmatic.js +110 -9
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +12 -5
- package/dist/site-manifest.js +25 -11
- package/dist/sync-routes.js +11 -2
- package/dist/topology.d.ts +131 -0
- package/dist/topology.js +533 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/apps/api/CLAUDE.md +4 -2
- 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/lib/__mocks__/prisma.ts +10 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +18 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +3 -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/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 +275 -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-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/lead-routing.ts +148 -29
- 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/CLAUDE.md +1 -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 +71 -19
- 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/openapi.json/__tests__/route.test.ts +89 -1
- package/templates/apps/web/app/openapi.json/route.ts +69 -1
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/relay.ts +206 -2
- 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 +23 -0
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +25 -0
- package/templates/database/__tests__/agents.db.test.ts +65 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/auth/schema.prisma +23 -0
- package/templates/database/inbox/schema.prisma +13 -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/package.json +1 -1
- package/versions.json +34 -33
|
@@ -43,7 +43,12 @@ import {
|
|
|
43
43
|
resolveDefaultWorkspaceRow as resolveDefaultWorkspaceRowShared,
|
|
44
44
|
} from "@working-theory/database/tenancy";
|
|
45
45
|
import type { Id } from "@working-theory/ids";
|
|
46
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
AGENT_GRANTABLE_PERMISSIONS,
|
|
48
|
+
can,
|
|
49
|
+
lowerRole,
|
|
50
|
+
type AgentGrantablePermission,
|
|
51
|
+
} from "@working-theory/validation";
|
|
47
52
|
|
|
48
53
|
import type { AgentGrant } from "../lib/agent-store.js";
|
|
49
54
|
import { prisma } from "../lib/prisma.js";
|
|
@@ -62,6 +67,13 @@ export interface TenantContext {
|
|
|
62
67
|
organizationId: Id<"organization">;
|
|
63
68
|
workspaceId: Id<"workspace">;
|
|
64
69
|
role: MembershipRole;
|
|
70
|
+
/**
|
|
71
|
+
* Set ONLY on an agent's tenant (#7864 D3), and only when at least one of
|
|
72
|
+
* its grants survives the clamp: the grantable permissions it holds on top
|
|
73
|
+
* of its role. A person's tenant never carries it. Read through
|
|
74
|
+
* `@working-theory/validation`'s `allows`.
|
|
75
|
+
*/
|
|
76
|
+
grants?: readonly AgentGrantablePermission[];
|
|
65
77
|
}
|
|
66
78
|
|
|
67
79
|
export type TenantErrorKind = "forbidden" | "not_found" | "conflict";
|
|
@@ -206,14 +218,39 @@ export async function resolveDefaultTenantForOperator(
|
|
|
206
218
|
return (await isPinnedOperator(userId)) ? resolveDefaultTenant(userId) : null;
|
|
207
219
|
}
|
|
208
220
|
|
|
221
|
+
/**
|
|
222
|
+
* An agent's tenant (#3591 D5, #7864): its person's tenant with the role
|
|
223
|
+
* clamped to the agent's ceiling and, when any survive, the agent's grants
|
|
224
|
+
* that its person's own role holds. `grants` is omitted when none survive,
|
|
225
|
+
* so an agent with no effective grant is the same shape as before.
|
|
226
|
+
*
|
|
227
|
+
* The one clamp both tenant guards apply — `resolveAgentTenant` (multi-tenant)
|
|
228
|
+
* and `resolveDefaultTenantForAgent` (base build) — so the two builds cannot
|
|
229
|
+
* hold an agent to different rules.
|
|
230
|
+
*/
|
|
231
|
+
export function agentTenant(
|
|
232
|
+
personTenant: TenantContext,
|
|
233
|
+
agent: AgentGrant,
|
|
234
|
+
): TenantContext {
|
|
235
|
+
const role = lowerRole(personTenant.role, agent.permissionLevel);
|
|
236
|
+
// D1 order, no duplicates, only the five, and only what the person holds.
|
|
237
|
+
const grants = AGENT_GRANTABLE_PERMISSIONS.filter(
|
|
238
|
+
(p) => agent.grants.includes(p) && can(personTenant.role, p),
|
|
239
|
+
);
|
|
240
|
+
return grants.length > 0
|
|
241
|
+
? { ...personTenant, role, grants }
|
|
242
|
+
: { ...personTenant, role };
|
|
243
|
+
}
|
|
244
|
+
|
|
209
245
|
/**
|
|
210
246
|
* The base-build grant for an AGENT (#3591 spec D5, D7). The same steps as
|
|
211
247
|
* `resolveDefaultTenantForOperator` — the pin check, then the default
|
|
212
248
|
* resolution and its one Membership read — so an agent and its person's
|
|
213
249
|
* session drive the same reads. On top of them: the resolved workspace must
|
|
214
250
|
* be the one the agent is bound to, and the Membership the read found must be
|
|
215
|
-
* the one the agent was created under. The
|
|
216
|
-
*
|
|
251
|
+
* the one the agent was created under. The tenant is then clamped
|
|
252
|
+
* (`agentTenant`), so the operator's agent acts as a member plus the grants
|
|
253
|
+
* the operator holds.
|
|
217
254
|
*
|
|
218
255
|
* The membership id is compared here rather than carried on `TenantContext`,
|
|
219
256
|
* which stays one shape for every caller. On a base build only the pinned,
|
|
@@ -234,10 +271,7 @@ export async function resolveDefaultTenantForAgent(
|
|
|
234
271
|
) {
|
|
235
272
|
return null;
|
|
236
273
|
}
|
|
237
|
-
return
|
|
238
|
-
...granted.tenant,
|
|
239
|
-
role: lowerRole(granted.tenant.role, agent.permissionLevel),
|
|
240
|
-
};
|
|
274
|
+
return agentTenant(granted.tenant, agent);
|
|
241
275
|
}
|
|
242
276
|
|
|
243
277
|
/**
|
|
@@ -27,9 +27,23 @@
|
|
|
27
27
|
* under it already contradicted it.) The one READ in this file — the
|
|
28
28
|
* duplicate-subscriber lookup on the P2002 arm — is scoped by it too
|
|
29
29
|
* (#6257).
|
|
30
|
+
*
|
|
31
|
+
* IDEMPOTENCY KEY (#7869, roadmap D5). A `submit` may carry a caller's
|
|
32
|
+
* `Idempotency-Key` (`routes/leads/index.ts` reads and validates the header;
|
|
33
|
+
* it travels beside the input as `routeLead`'s third argument, never inside
|
|
34
|
+
* the `.strict()` input). On `inbox` delivery only, a key already stored on a
|
|
35
|
+
* `Lead` of THIS workspace short-circuits the write: the same `email`,
|
|
36
|
+
* `source` and `payload` is a replay (the first lead's id, nothing written,
|
|
37
|
+
* no capture record, no notification); anything else is an
|
|
38
|
+
* `idempotency_conflict` (nothing written). The two reads — the lookup
|
|
39
|
+
* before the write and the re-read after a `P2002` race — are both keyed on
|
|
40
|
+
* `(workspaceId, idempotencyKey)`, so a key can never reach another tenant's
|
|
41
|
+
* row. `email`, `local` and `none` deliveries ignore the key: there is no
|
|
42
|
+
* row to dedupe against. The key itself is never logged.
|
|
30
43
|
*/
|
|
31
44
|
|
|
32
45
|
import { randomUUID } from "node:crypto";
|
|
46
|
+
import { isDeepStrictEqual } from "node:util";
|
|
33
47
|
|
|
34
48
|
import type { Prisma } from "@working-theory/database";
|
|
35
49
|
import { sendLeadNotification } from "@working-theory/email";
|
|
@@ -79,9 +93,21 @@ export type SourceMeta = {
|
|
|
79
93
|
/** Where the lead landed: a database row, the local file fallback, or the owner's inbox by email (#6730). */
|
|
80
94
|
export type Persisted = "db" | "file" | "email";
|
|
81
95
|
|
|
96
|
+
/** Per-call options carried beside the input (#7869 D2). */
|
|
97
|
+
export type RouteLeadOptions = {
|
|
98
|
+
/** The caller's validated `Idempotency-Key`; honoured for `submit` only. */
|
|
99
|
+
idempotencyKey?: string;
|
|
100
|
+
};
|
|
101
|
+
|
|
82
102
|
export type RouteLeadResult =
|
|
83
103
|
| {
|
|
84
104
|
ok: true;
|
|
105
|
+
/**
|
|
106
|
+
* The answer to a repeat of an already-stored idempotency key (#7869
|
|
107
|
+
* D4): nothing was written. Never on the wire — the route builds its
|
|
108
|
+
* 201 body from `id`, `persisted` and `email` only.
|
|
109
|
+
*/
|
|
110
|
+
replayed?: true;
|
|
85
111
|
id: string;
|
|
86
112
|
persisted: Persisted;
|
|
87
113
|
email:
|
|
@@ -96,7 +122,8 @@ export type RouteLeadResult =
|
|
|
96
122
|
| "invalid_input"
|
|
97
123
|
| "persist_failed"
|
|
98
124
|
| "config_missing"
|
|
99
|
-
| "delivery_failed"
|
|
125
|
+
| "delivery_failed"
|
|
126
|
+
| "idempotency_conflict";
|
|
100
127
|
message: string;
|
|
101
128
|
};
|
|
102
129
|
};
|
|
@@ -116,6 +143,64 @@ function validateInput(
|
|
|
116
143
|
return { ok: true };
|
|
117
144
|
}
|
|
118
145
|
|
|
146
|
+
// ─── Idempotency key (#7869) ─────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
type SubmitInput = Extract<RouteLeadInput, { kind: "submit" }>;
|
|
149
|
+
type KeyedRow = { id: string; email: string; source: string; payload: unknown };
|
|
150
|
+
/** What a stored key means for this request (#7869 D4). */
|
|
151
|
+
type KeyedOutcome = { kind: "replay"; id: string } | { kind: "conflict" };
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The one read of `Lead.idempotencyKey` (#7869 D3, reused by D5's re-read).
|
|
155
|
+
* The compound unique carries `workspaceId`, so the lookup can only ever
|
|
156
|
+
* reach this workspace's rows.
|
|
157
|
+
*/
|
|
158
|
+
async function findLeadByKey(
|
|
159
|
+
workspaceId: string,
|
|
160
|
+
idempotencyKey: string,
|
|
161
|
+
): Promise<KeyedRow | null> {
|
|
162
|
+
return (await prisma.lead.findUnique({
|
|
163
|
+
where: { workspaceId_idempotencyKey: { workspaceId, idempotencyKey } },
|
|
164
|
+
select: { id: true, email: true, source: true, payload: true },
|
|
165
|
+
})) as KeyedRow | null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A stored row is a replay only when it carries this request's exact `email`
|
|
170
|
+
* and `source` and a deep-equal `payload` (key order ignored, SEC-7869-3);
|
|
171
|
+
* any other row is a conflict (#7869 D4).
|
|
172
|
+
*/
|
|
173
|
+
function classifyKeyedRow(row: KeyedRow, input: SubmitInput): KeyedOutcome {
|
|
174
|
+
return row.email === input.email &&
|
|
175
|
+
row.source === input.source &&
|
|
176
|
+
isDeepStrictEqual(row.payload, input.payload)
|
|
177
|
+
? { kind: "replay", id: row.id }
|
|
178
|
+
: { kind: "conflict" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function keyedResult(outcome: KeyedOutcome): RouteLeadResult {
|
|
182
|
+
if (outcome.kind === "conflict") {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
error: { kind: "idempotency_conflict", message: "idempotency key reused" },
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
replayed: true,
|
|
191
|
+
id: outcome.id,
|
|
192
|
+
persisted: "db",
|
|
193
|
+
email: { skipped: true, reason: "idempotent_replay" },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The structural `P2002` test `recommender-capture.ts` already uses. */
|
|
198
|
+
function isUniqueViolation(err: unknown): boolean {
|
|
199
|
+
return (
|
|
200
|
+
typeof err === "object" && err !== null && (err as { code?: unknown }).code === "P2002"
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
119
204
|
// ─── DB persist ───────────────────────────────────────────────────────────────
|
|
120
205
|
|
|
121
206
|
type DbRow = { id: string };
|
|
@@ -127,36 +212,55 @@ type PersistResult =
|
|
|
127
212
|
skipEmail: boolean;
|
|
128
213
|
skipReason?: string;
|
|
129
214
|
}
|
|
215
|
+
// #7869 D5 — a keyed create lost a race to a row it now answers with.
|
|
216
|
+
| { ok: "keyed"; outcome: KeyedOutcome }
|
|
130
217
|
| { ok: false; error: string };
|
|
131
218
|
|
|
132
|
-
async function persistToDb(
|
|
219
|
+
async function persistToDb(
|
|
220
|
+
input: RouteLeadInput,
|
|
221
|
+
idempotencyKey?: string,
|
|
222
|
+
): Promise<PersistResult> {
|
|
133
223
|
if (input.kind === "submit") {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
224
|
+
let row: DbRow;
|
|
225
|
+
try {
|
|
226
|
+
row = (await prisma.lead.create({
|
|
227
|
+
data: {
|
|
228
|
+
siteId: input.siteId,
|
|
229
|
+
// #6203/#6254 — the isolation key, equal to `siteId` by
|
|
230
|
+
// construction. `input.siteId` is the RESOLVED workspace id at
|
|
231
|
+
// EVERY `routeLead(kind: "submit")` caller now — the primary route
|
|
232
|
+
// (`routes/leads/index.ts`, never reads it from the body — see that
|
|
233
|
+
// file's header) and the three service-layer callers
|
|
234
|
+
// (`flow-engine.ts`'s `completeSubmission`,
|
|
235
|
+
// `recommender-capture.ts`'s `captureRecommenderChoice`,
|
|
236
|
+
// `project-listings.ts`'s `submitProjectListingDraft`) all resolve
|
|
237
|
+
// the site alias before calling in (#6254 AC-6/AC-10). `workspaceId`
|
|
238
|
+
// is NOT NULL as of
|
|
239
|
+
// database/migrations/20260922120000_tenancy_fence, so this can no
|
|
240
|
+
// longer stamp `null` for an unresolved alias the way it once did
|
|
241
|
+
// (spec D2's old rule) — a caller that somehow still passed one
|
|
242
|
+
// would instead hit the pre-existing `<table>_workspace_id_format`
|
|
243
|
+
// CHECK loudly, via the AC-11 re-raise below, rather than landing a
|
|
244
|
+
// silently-unfenced row.
|
|
245
|
+
workspaceId: input.siteId,
|
|
246
|
+
source: input.source,
|
|
247
|
+
email: input.email,
|
|
248
|
+
payload: input.payload as Prisma.InputJsonValue,
|
|
249
|
+
...(idempotencyKey !== undefined && { idempotencyKey }),
|
|
250
|
+
},
|
|
251
|
+
select: { id: true },
|
|
252
|
+
})) as DbRow;
|
|
253
|
+
} catch (err) {
|
|
254
|
+
// #7869 D5 — two keyed requests both missed the lookup and both
|
|
255
|
+
// created; the unique index refused the second. Answer with the
|
|
256
|
+
// winner. Anything else — no key, another error, a winner that cannot
|
|
257
|
+
// be read back — is rethrown untouched, so `routeLead`'s existing
|
|
258
|
+
// constraint and outage handling decide it exactly as before.
|
|
259
|
+
if (idempotencyKey === undefined || !isUniqueViolation(err)) throw err;
|
|
260
|
+
const winner = await findLeadByKey(input.siteId, idempotencyKey).catch(() => null);
|
|
261
|
+
if (!winner) throw err;
|
|
262
|
+
return { ok: "keyed", outcome: classifyKeyedRow(winner, input) };
|
|
263
|
+
}
|
|
160
264
|
return { ok: true, id: row.id, source: "db", skipEmail: false };
|
|
161
265
|
}
|
|
162
266
|
|
|
@@ -426,6 +530,7 @@ function captureRecordFor(input: RouteLeadInput, id: string, sourceMeta: SourceM
|
|
|
426
530
|
export async function routeLead(
|
|
427
531
|
input: RouteLeadInput,
|
|
428
532
|
sourceMeta: SourceMeta,
|
|
533
|
+
options: RouteLeadOptions = {},
|
|
429
534
|
): Promise<RouteLeadResult> {
|
|
430
535
|
// 1. Validate
|
|
431
536
|
const validation = validateInput(input);
|
|
@@ -452,6 +557,17 @@ export async function routeLead(
|
|
|
452
557
|
};
|
|
453
558
|
}
|
|
454
559
|
|
|
560
|
+
// 2a. Idempotency key (#7869 D3/D7) — submit, a key, and a database to
|
|
561
|
+
// dedupe against. A stored key answers before any enrichment or write. A
|
|
562
|
+
// failed read is not an answer: the create below meets the same database
|
|
563
|
+
// and takes the existing outage path.
|
|
564
|
+
const idempotencyKey =
|
|
565
|
+
input.kind === "submit" && delivery === "inbox" ? options.idempotencyKey : undefined;
|
|
566
|
+
if (idempotencyKey !== undefined && input.kind === "submit") {
|
|
567
|
+
const stored = await findLeadByKey(input.siteId, idempotencyKey).catch(() => null);
|
|
568
|
+
if (stored) return keyedResult(classifyKeyedRow(stored, input));
|
|
569
|
+
}
|
|
570
|
+
|
|
455
571
|
// 3. Enrich (reserved for Phase 3 — today returns lead unchanged).
|
|
456
572
|
// Cast via unknown: enrichLead accepts CreateLead (B3 shim type) but the
|
|
457
573
|
// pass-through contract means RouteLeadInput is safe here until Phase 3 wires
|
|
@@ -484,7 +600,10 @@ export async function routeLead(
|
|
|
484
600
|
let skipReason: string | undefined;
|
|
485
601
|
|
|
486
602
|
try {
|
|
487
|
-
const dbResult = await persistToDb(input);
|
|
603
|
+
const dbResult = await persistToDb(input, idempotencyKey);
|
|
604
|
+
// #7869 D5 — a lost race answers like the lookup above: no capture
|
|
605
|
+
// record, no notification.
|
|
606
|
+
if (dbResult.ok === "keyed") return keyedResult(dbResult.outcome);
|
|
488
607
|
if (!dbResult.ok) {
|
|
489
608
|
// persistToDb returned ok:false — defensive guard; current impl doesn't reach this
|
|
490
609
|
return {
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// topology.generated.ts — how this MCP server resolves its workspace store.
|
|
2
|
+
//
|
|
3
|
+
// GENERATED by create-ailk for this site's topology (#7426). Do not edit:
|
|
4
|
+
// every scaffold rewrites it. A hub resolves its workspace through the
|
|
5
|
+
// database; a spoke (`--topology spoke`) has none, so its content tools
|
|
6
|
+
// read the spoke's own MDX and its writes go to the hub at AILK_API_URL.
|
|
7
|
+
import {
|
|
8
|
+
initMcpPublicWorkspace,
|
|
9
|
+
mcpPublicWorkspace,
|
|
10
|
+
type McpWorkspaceResolution,
|
|
11
|
+
} from "./public-workspace.js";
|
|
12
|
+
|
|
13
|
+
export { registerLicenseKeyStore } from "./license-key-store.js";
|
|
14
|
+
export type { McpWorkspaceResolution };
|
|
15
|
+
|
|
16
|
+
export const TOPOLOGY: "hub" | "spoke" = "hub";
|
|
17
|
+
|
|
18
|
+
export async function initPublicWorkspace(): Promise<void> {
|
|
19
|
+
await initMcpPublicWorkspace();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function publicWorkspace(): McpWorkspaceResolution {
|
|
23
|
+
return mcpPublicWorkspace();
|
|
24
|
+
}
|
|
@@ -139,6 +139,7 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
|
|
|
139
139
|
- **Only the list.** `RELAY_ROUTES` is the fixed set of (method, path) pairs: `POST /v1/leads/{submit,subscribe,callback}`, `GET /v1/schedule/availability`, `POST /v1/schedule/book`, `POST /v1/commerce/invoices`. Anything else is a 404 with no hub call. Adding a route is a reviewed change to that list, its test and `.env.example`.
|
|
140
140
|
- **No cross-site POST.** A `Sec-Fetch-Site` other than `same-origin`, or an `Origin` not on this site's own host, is refused 403 before the hub is called.
|
|
141
141
|
- **Headers are an allow-list.** The visitor's `x-forwarded-for` (else `x-real-ip`) and `user-agent` pass through so per-visitor rate limits still count each visitor; no `authorization`, client-sent `x-ailk-*` or `host` is forwarded, and a `cookie` goes only on `POST /v1/commerce/invoices`, the one session route.
|
|
142
|
+
- **The relay has its own per-visitor budget (#7707).** Each visitor gets 8 calls per 10 minutes across the five visitor routes, and the session route (`POST /v1/commerce/invoices`) gets its own, wider budget of 30 per 10 minutes; the call that exceeds either gets `429 rate_limited` before any hub call. The budget is held in this instance's own process (no shared store, no env variable) and holds only where the site's edge overwrites `x-forwarded-for` and `x-real-ip` — Vercel does; a bare self-hosted Next server does not.
|
|
142
143
|
- `/v1` is exempt from next-intl's locale rewrite in `middleware.ts`, as `/api` is.
|
|
143
144
|
|
|
144
145
|
## AEO & performance
|
|
@@ -51,6 +51,9 @@ jest.mock("../../../../../lib/paid-sections.generated", () => {
|
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
jest.mock("@working-theory/ui", () => ({
|
|
54
|
+
// #7789: renderPage's fallback page head renders inside the section Container.
|
|
55
|
+
Container: ({ children }: { children?: React.ReactNode }) =>
|
|
56
|
+
React.createElement("div", null, children),
|
|
54
57
|
// #7521 — the rest of the real barrel, so the listing extension finds
|
|
55
58
|
// `localeHref`; the two below still override.
|
|
56
59
|
...jest.requireActual("@working-theory/ui"),
|
|
@@ -24,6 +24,9 @@ jest.mock("next/navigation", () => ({
|
|
|
24
24
|
}));
|
|
25
25
|
|
|
26
26
|
jest.mock("@working-theory/ui", () => ({
|
|
27
|
+
// #7789: renderPage's fallback page head renders inside the section Container.
|
|
28
|
+
Container: ({ children }: { children?: React.ReactNode }) =>
|
|
29
|
+
React.createElement("div", null, children),
|
|
27
30
|
Heading: ({
|
|
28
31
|
children,
|
|
29
32
|
level,
|