create-ailk 0.13.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/README.md +12 -4
- package/component-catalog.json +250 -36
- package/component-catalog.md +10 -6
- 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 +32 -2
- package/dist/apply-design.js +155 -22
- package/dist/cli.js +24 -3
- package/dist/derive-architecture.d.ts +15 -2
- package/dist/derive-architecture.js +34 -6
- package/dist/design-compile.d.ts +70 -2
- package/dist/design-compile.js +97 -1
- package/dist/design-home.d.ts +74 -0
- package/dist/design-home.js +256 -0
- package/dist/design-package.d.ts +39 -2
- package/dist/design-package.js +224 -9
- package/dist/design-presets.d.ts +17 -1
- package/dist/design-presets.js +17 -5
- package/dist/design-scaffold.d.ts +6 -25
- package/dist/design-scaffold.js +26 -74
- package/dist/index.d.ts +2 -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/design-checks.d.ts +12 -1
- package/dist/lib/design-checks.js +38 -2
- 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/lib/module-license-gate.d.ts +9 -0
- package/dist/lib/module-license-gate.js +18 -0
- 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 +180 -35
- package/dist/site-chrome.d.ts +42 -0
- package/dist/site-chrome.js +140 -2
- package/dist/site-fonts.d.ts +21 -0
- package/dist/site-fonts.js +54 -0
- package/dist/site-manifest.d.ts +12 -5
- package/dist/site-manifest.js +25 -11
- package/dist/site-roster.d.ts +16 -0
- package/dist/site-roster.js +81 -0
- package/dist/starter-grid.js +15 -0
- package/dist/surfaces.js +12 -0
- package/dist/sync-routes.js +11 -2
- package/dist/team-profiles.d.ts +29 -0
- package/dist/team-profiles.js +43 -0
- package/dist/topology.d.ts +131 -0
- package/dist/topology.js +533 -0
- package/package.json +3 -3
- package/release-scope.json +1 -1
- package/site-templates/_base/content/en/TeamList/team.mdx +68 -0
- package/site-templates/_base/content/en/TeamMember/first-member.mdx +22 -0
- package/site-templates/_base/content/en/TeamMember/second-member.mdx +22 -0
- package/site-templates/_base/content/en/TeamMember/third-member.mdx +22 -0
- package/site-templates/designs/minimalism/DESIGN.md +166 -36
- package/site-templates/designs/minimalism/home/local-business-service.mdx +119 -0
- package/site-templates/designs/minimalism/home/organization-product.mdx +132 -0
- package/site-templates/designs/minimalism/home/organization-service.mdx +131 -0
- package/site-templates/designs/minimalism/home/person-service.mdx +101 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +20 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +22 -1
- package/site-templates/marketing-site-free/ailk-template.json +1 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -7
- package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -7
- package/site-templates/shapes/organization-service/ailk-template.json +1 -1
- package/templates/README.md +2 -0
- package/templates/apps/api/CLAUDE.md +6 -1
- 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/package.json +1 -1
- 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 +12 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +10 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/__tests__/theme-modes.test.ts +42 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +8 -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]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +14 -1
- 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 +163 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +68 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +15 -3
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +17 -6
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +4 -6
- package/templates/apps/web/app/[locale]/layout.tsx +212 -57
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/page.tsx +37 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +4 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +15 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +43 -6
- package/templates/apps/web/app/[locale]/services/page.tsx +14 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/team/[slug]/page.tsx +45 -0
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +155 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +49 -0
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/not-found.tsx +47 -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__/profile-back-link.test.ts +95 -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/__tests__/site-theme-head.test.ts +70 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +64 -1
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/profile-back-link.ts +51 -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 +15 -0
- package/templates/apps/web/lib/site-fonts.ts +15 -0
- package/templates/apps/web/lib/site-logo.tsx +39 -1
- package/templates/apps/web/lib/site-theme-head.ts +33 -0
- package/templates/apps/web/lib/site-theme.ts +13 -1
- package/templates/apps/web/lib/topology.generated.ts +23 -0
- package/templates/apps/web/messages/en.json +7 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +32 -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 +35 -34
- package/site-templates/designs/minimalism/sample/README.md +0 -48
- package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +0 -244
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-1.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-2.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-3.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-4.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-5.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-6.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/hero-backdrop.svg +0 -17
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-1.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-2.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-3.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/wordmark-placeholder.svg +0 -28
|
@@ -747,3 +747,202 @@ describe("#6730 — routeLead delivery modes", () => {
|
|
|
747
747
|
});
|
|
748
748
|
});
|
|
749
749
|
});
|
|
750
|
+
|
|
751
|
+
// ── #7869 — the Idempotency-Key on submit (spec D3–D5, D10 cases 9–14) ──────
|
|
752
|
+
//
|
|
753
|
+
// Cases 9–13 run on `inbox` delivery (DATABASE_URL set), where the key is
|
|
754
|
+
// honoured; case 14 drops DATABASE_URL to prove a non-inbox delivery ignores
|
|
755
|
+
// it. A keyed create that loses the race throws P2002 and is answered from a
|
|
756
|
+
// re-read of the winner.
|
|
757
|
+
describe("#7869 — routeLead with an idempotency key", () => {
|
|
758
|
+
const KEY = "fixture-retry-0000-0000-0001";
|
|
759
|
+
const WINNER_ID = "lead_00000000-0000-7000-8000-00000000f001";
|
|
760
|
+
const ENV_KEYS = [
|
|
761
|
+
"DATABASE_URL",
|
|
762
|
+
"NODE_ENV",
|
|
763
|
+
"AILK_INBOX_FALLBACK_DIR",
|
|
764
|
+
"LEAD_CAPTURE_FILE",
|
|
765
|
+
"AILK_LEAD_NOTIFICATION_TO",
|
|
766
|
+
"AILK_LEAD_NOTIFICATION_FROM",
|
|
767
|
+
] as const;
|
|
768
|
+
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
769
|
+
let tmpDir: string;
|
|
770
|
+
|
|
771
|
+
const p2002 = () =>
|
|
772
|
+
Object.assign(
|
|
773
|
+
new Error(
|
|
774
|
+
"Unique constraint failed on the fields: (`workspace_id`,`idempotency_key`)",
|
|
775
|
+
),
|
|
776
|
+
{ code: "P2002" },
|
|
777
|
+
);
|
|
778
|
+
const keyWhere = (workspaceId: string) => ({
|
|
779
|
+
where: { workspaceId_idempotencyKey: { workspaceId, idempotencyKey: KEY } },
|
|
780
|
+
select: { id: true, email: true, source: true, payload: true },
|
|
781
|
+
});
|
|
782
|
+
const createData = () =>
|
|
783
|
+
jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data as Record<
|
|
784
|
+
string,
|
|
785
|
+
unknown
|
|
786
|
+
>;
|
|
787
|
+
|
|
788
|
+
beforeEach(async () => {
|
|
789
|
+
jest.clearAllMocks();
|
|
790
|
+
saved = {};
|
|
791
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
792
|
+
for (const k of ENV_KEYS) delete process.env[k];
|
|
793
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-idem-test-"));
|
|
794
|
+
process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
|
|
795
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
796
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
797
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
798
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_new" });
|
|
799
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
|
|
800
|
+
mockSendLeadNotification.mockResolvedValue({
|
|
801
|
+
ok: true,
|
|
802
|
+
messageId: "msg-1",
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
afterEach(async () => {
|
|
807
|
+
for (const k of ENV_KEYS) {
|
|
808
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
809
|
+
else process.env[k] = saved[k];
|
|
810
|
+
}
|
|
811
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it("case 9 — a P2002 race whose winner matches answers the winner's id: no fallback, no persist_failed, no notification, no capture record", async () => {
|
|
815
|
+
// Positive control first: a keyed create with no race writes exactly one
|
|
816
|
+
// line to the capture file, so the negative assertion below is load-bearing.
|
|
817
|
+
const controlFile = path.join(tmpDir, "control.jsonl");
|
|
818
|
+
process.env.LEAD_CAPTURE_FILE = controlFile;
|
|
819
|
+
const control = await routeLead(leadInput, sourceMeta, {
|
|
820
|
+
idempotencyKey: KEY,
|
|
821
|
+
});
|
|
822
|
+
expect(control).toMatchObject({
|
|
823
|
+
ok: true,
|
|
824
|
+
id: "lead_db_new",
|
|
825
|
+
persisted: "db",
|
|
826
|
+
});
|
|
827
|
+
expect(
|
|
828
|
+
(await fs.readFile(controlFile, "utf8")).trim().split("\n"),
|
|
829
|
+
).toHaveLength(1);
|
|
830
|
+
expect(createData()).toMatchObject({ idempotencyKey: KEY });
|
|
831
|
+
|
|
832
|
+
jest.clearAllMocks();
|
|
833
|
+
const raceFile = path.join(tmpDir, "race.jsonl");
|
|
834
|
+
process.env.LEAD_CAPTURE_FILE = raceFile;
|
|
835
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
|
|
836
|
+
(prisma.lead.findUnique as jest.Mock)
|
|
837
|
+
.mockResolvedValueOnce(null) // the pre-write lookup misses
|
|
838
|
+
.mockResolvedValueOnce({
|
|
839
|
+
id: WINNER_ID,
|
|
840
|
+
email: leadInput.email,
|
|
841
|
+
source: leadInput.source,
|
|
842
|
+
payload: { ...leadInput.payload },
|
|
843
|
+
});
|
|
844
|
+
const before = await fs.readdir(tmpDir);
|
|
845
|
+
|
|
846
|
+
const result = await routeLead(leadInput, sourceMeta, {
|
|
847
|
+
idempotencyKey: KEY,
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
expect(result).toEqual({
|
|
851
|
+
ok: true,
|
|
852
|
+
replayed: true,
|
|
853
|
+
id: WINNER_ID,
|
|
854
|
+
persisted: "db",
|
|
855
|
+
email: { skipped: true, reason: "idempotent_replay" },
|
|
856
|
+
});
|
|
857
|
+
expect(prisma.lead.findUnique).toHaveBeenNthCalledWith(
|
|
858
|
+
2,
|
|
859
|
+
keyWhere(leadInput.siteId),
|
|
860
|
+
);
|
|
861
|
+
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
862
|
+
await expect(fs.access(raceFile)).rejects.toThrow();
|
|
863
|
+
expect(await fs.readdir(tmpDir)).toEqual(before); // no file fallback
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it("case 10 — a P2002 race whose winner carries a different email is idempotency_conflict", async () => {
|
|
867
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
|
|
868
|
+
(prisma.lead.findUnique as jest.Mock)
|
|
869
|
+
.mockResolvedValueOnce(null)
|
|
870
|
+
.mockResolvedValueOnce({
|
|
871
|
+
id: WINNER_ID,
|
|
872
|
+
email: "someone-else@example.com",
|
|
873
|
+
source: leadInput.source,
|
|
874
|
+
payload: leadInput.payload,
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
const result = await routeLead(leadInput, sourceMeta, {
|
|
878
|
+
idempotencyKey: KEY,
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
expect(result).toEqual({
|
|
882
|
+
ok: false,
|
|
883
|
+
error: {
|
|
884
|
+
kind: "idempotency_conflict",
|
|
885
|
+
message: "idempotency key reused",
|
|
886
|
+
},
|
|
887
|
+
});
|
|
888
|
+
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it("case 11 — a P2002 race whose re-read finds nothing takes the existing persist_failed path", async () => {
|
|
892
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
|
|
893
|
+
|
|
894
|
+
const result = (await routeLead(leadInput, sourceMeta, {
|
|
895
|
+
idempotencyKey: KEY,
|
|
896
|
+
})) as Extract<RouteLeadResult, { ok: false }>;
|
|
897
|
+
|
|
898
|
+
expect(result.ok).toBe(false);
|
|
899
|
+
expect(result.error.kind).toBe("persist_failed");
|
|
900
|
+
expect(prisma.lead.findUnique).toHaveBeenCalledTimes(2);
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it("case 12 — a P2002 with no key passed is persist_failed, as today, with no key lookup", async () => {
|
|
904
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
|
|
905
|
+
|
|
906
|
+
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
907
|
+
RouteLeadResult,
|
|
908
|
+
{ ok: false }
|
|
909
|
+
>;
|
|
910
|
+
|
|
911
|
+
expect(result.ok).toBe(false);
|
|
912
|
+
expect(result.error.kind).toBe("persist_failed");
|
|
913
|
+
expect(prisma.lead.findUnique).not.toHaveBeenCalled();
|
|
914
|
+
expect(createData()).not.toHaveProperty("idempotencyKey");
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it("case 13 — a lookup that throws still reaches the create (the existing path)", async () => {
|
|
918
|
+
(prisma.lead.findUnique as jest.Mock).mockRejectedValue(
|
|
919
|
+
new Error("connection reset"),
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
const result = await routeLead(leadInput, sourceMeta, {
|
|
923
|
+
idempotencyKey: KEY,
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
927
|
+
expect(result).toMatchObject({
|
|
928
|
+
ok: true,
|
|
929
|
+
id: "lead_db_new",
|
|
930
|
+
persisted: "db",
|
|
931
|
+
});
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it("case 14 — a non-inbox delivery ignores the key: no lookup, no key on the create, the existing file path", async () => {
|
|
935
|
+
delete process.env.DATABASE_URL; // `local` delivery outside production
|
|
936
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(
|
|
937
|
+
new Error("connection refused"),
|
|
938
|
+
);
|
|
939
|
+
|
|
940
|
+
const result = await routeLead(leadInput, sourceMeta, {
|
|
941
|
+
idempotencyKey: KEY,
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
expect(prisma.lead.findUnique).not.toHaveBeenCalled();
|
|
945
|
+
expect(createData()).not.toHaveProperty("idempotencyKey");
|
|
946
|
+
expect(result).toMatchObject({ ok: true, persisted: "file" });
|
|
947
|
+
});
|
|
948
|
+
});
|
|
@@ -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);
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"@fastify/cors": "^11.2.0",
|
|
20
20
|
"@fastify/rate-limit": "^10.3.0",
|
|
21
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
21
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
22
22
|
"@prisma/adapter-pg": "^7.8.0",
|
|
23
23
|
"@working-theory/aeo": "workspace:*",
|
|
24
24
|
"@working-theory/api-client": "workspace:*",
|
|
@@ -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
|
}
|