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.
Files changed (188) hide show
  1. package/README.md +12 -4
  2. package/component-catalog.json +250 -36
  3. package/component-catalog.md +10 -6
  4. package/dist/add-module.d.ts +7 -0
  5. package/dist/add-module.js +34 -2
  6. package/dist/add-route.js +18 -2
  7. package/dist/apply-design.d.ts +32 -2
  8. package/dist/apply-design.js +155 -22
  9. package/dist/cli.js +24 -3
  10. package/dist/derive-architecture.d.ts +15 -2
  11. package/dist/derive-architecture.js +34 -6
  12. package/dist/design-compile.d.ts +70 -2
  13. package/dist/design-compile.js +97 -1
  14. package/dist/design-home.d.ts +74 -0
  15. package/dist/design-home.js +256 -0
  16. package/dist/design-package.d.ts +39 -2
  17. package/dist/design-package.js +224 -9
  18. package/dist/design-presets.d.ts +17 -1
  19. package/dist/design-presets.js +17 -5
  20. package/dist/design-scaffold.d.ts +6 -25
  21. package/dist/design-scaffold.js +26 -74
  22. package/dist/index.d.ts +2 -1
  23. package/dist/index.js +1 -1
  24. package/dist/lib/chrome-readers.d.ts +21 -0
  25. package/dist/lib/chrome-readers.js +100 -0
  26. package/dist/lib/design-checks.d.ts +12 -1
  27. package/dist/lib/design-checks.js +38 -2
  28. package/dist/lib/extract-module-bundle.d.ts +11 -0
  29. package/dist/lib/extract-module-bundle.js +36 -6
  30. package/dist/lib/license-key-files.d.ts +6 -1
  31. package/dist/lib/license-key-files.js +9 -4
  32. package/dist/lib/module-license-gate.d.ts +9 -0
  33. package/dist/lib/module-license-gate.js +18 -0
  34. package/dist/module-architecture.d.ts +9 -0
  35. package/dist/module-architecture.js +6 -0
  36. package/dist/parse-args.d.ts +22 -0
  37. package/dist/parse-args.js +73 -0
  38. package/dist/programmatic.d.ts +20 -0
  39. package/dist/programmatic.js +180 -35
  40. package/dist/site-chrome.d.ts +42 -0
  41. package/dist/site-chrome.js +140 -2
  42. package/dist/site-fonts.d.ts +21 -0
  43. package/dist/site-fonts.js +54 -0
  44. package/dist/site-manifest.d.ts +12 -5
  45. package/dist/site-manifest.js +25 -11
  46. package/dist/site-roster.d.ts +16 -0
  47. package/dist/site-roster.js +81 -0
  48. package/dist/starter-grid.js +15 -0
  49. package/dist/surfaces.js +12 -0
  50. package/dist/sync-routes.js +11 -2
  51. package/dist/team-profiles.d.ts +29 -0
  52. package/dist/team-profiles.js +43 -0
  53. package/dist/topology.d.ts +131 -0
  54. package/dist/topology.js +533 -0
  55. package/package.json +3 -3
  56. package/release-scope.json +1 -1
  57. package/site-templates/_base/content/en/TeamList/team.mdx +68 -0
  58. package/site-templates/_base/content/en/TeamMember/first-member.mdx +22 -0
  59. package/site-templates/_base/content/en/TeamMember/second-member.mdx +22 -0
  60. package/site-templates/_base/content/en/TeamMember/third-member.mdx +22 -0
  61. package/site-templates/designs/minimalism/DESIGN.md +166 -36
  62. package/site-templates/designs/minimalism/home/local-business-service.mdx +119 -0
  63. package/site-templates/designs/minimalism/home/organization-product.mdx +132 -0
  64. package/site-templates/designs/minimalism/home/organization-service.mdx +131 -0
  65. package/site-templates/designs/minimalism/home/person-service.mdx +101 -0
  66. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +34 -1
  67. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +20 -0
  68. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +34 -1
  69. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +22 -1
  70. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  71. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +34 -1
  72. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -7
  73. package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
  74. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +34 -1
  75. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -7
  76. package/site-templates/shapes/organization-service/ailk-template.json +1 -1
  77. package/templates/README.md +2 -0
  78. package/templates/apps/api/CLAUDE.md +6 -1
  79. package/templates/apps/api/package.json +1 -0
  80. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  81. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  82. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  83. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  84. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  85. package/templates/apps/api/src/lib/site.ts +37 -0
  86. package/templates/apps/api/src/lib/spoke-store.ts +18 -2
  87. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  88. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  89. package/templates/apps/api/src/middleware/auth.ts +3 -0
  90. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  91. package/templates/apps/api/src/openapi/spec.ts +38 -2
  92. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  93. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  94. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
  95. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  96. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  97. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  98. package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
  99. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
  100. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  101. package/templates/apps/api/src/services/lead-routing.ts +148 -29
  102. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  103. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  104. package/templates/apps/mcp/package.json +1 -1
  105. package/templates/apps/mcp/src/index.ts +6 -4
  106. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  107. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  108. package/templates/apps/web/CLAUDE.md +12 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +10 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/__tests__/theme-modes.test.ts +42 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +8 -0
  112. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  113. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  115. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  116. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +14 -1
  117. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  118. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +163 -0
  119. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +68 -0
  120. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +4 -4
  121. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +15 -3
  122. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +17 -6
  123. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +14 -1
  124. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +2 -2
  125. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +4 -6
  126. package/templates/apps/web/app/[locale]/layout.tsx +212 -57
  127. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  128. package/templates/apps/web/app/[locale]/page.tsx +37 -0
  129. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  130. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +4 -6
  131. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  132. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +15 -2
  133. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +43 -6
  134. package/templates/apps/web/app/[locale]/services/page.tsx +14 -2
  135. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +2 -2
  136. package/templates/apps/web/app/[locale]/team/[slug]/page.tsx +45 -0
  137. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +155 -0
  138. package/templates/apps/web/app/[locale]/team/page.tsx +49 -0
  139. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +2 -2
  140. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +14 -1
  141. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  142. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +2 -2
  143. package/templates/apps/web/app/not-found.tsx +47 -0
  144. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
  145. package/templates/apps/web/app/openapi.json/route.ts +69 -1
  146. package/templates/apps/web/instrumentation.ts +7 -4
  147. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  148. package/templates/apps/web/lib/__tests__/profile-back-link.test.ts +95 -0
  149. package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
  150. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  151. package/templates/apps/web/lib/__tests__/site-theme-head.test.ts +70 -0
  152. package/templates/apps/web/lib/__tests__/site-theme.test.ts +64 -1
  153. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  154. package/templates/apps/web/lib/profile-back-link.ts +51 -0
  155. package/templates/apps/web/lib/relay.ts +206 -2
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +15 -0
  158. package/templates/apps/web/lib/site-fonts.ts +15 -0
  159. package/templates/apps/web/lib/site-logo.tsx +39 -1
  160. package/templates/apps/web/lib/site-theme-head.ts +33 -0
  161. package/templates/apps/web/lib/site-theme.ts +13 -1
  162. package/templates/apps/web/lib/topology.generated.ts +23 -0
  163. package/templates/apps/web/messages/en.json +7 -1
  164. package/templates/apps/web/package.json +1 -1
  165. package/templates/database/CHANGELOG.md +32 -0
  166. package/templates/database/__tests__/agents.db.test.ts +65 -0
  167. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  168. package/templates/database/auth/schema.prisma +23 -0
  169. package/templates/database/inbox/schema.prisma +13 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/package.json +1 -1
  175. package/versions.json +35 -34
  176. package/site-templates/designs/minimalism/sample/README.md +0 -48
  177. package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +0 -244
  178. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-1.svg +0 -7
  179. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-2.svg +0 -7
  180. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-3.svg +0 -7
  181. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-4.svg +0 -7
  182. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-5.svg +0 -7
  183. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-6.svg +0 -7
  184. package/site-templates/designs/minimalism/sample/public/hero-backdrop.svg +0 -17
  185. package/site-templates/designs/minimalism/sample/public/logo-placeholder-1.svg +0 -6
  186. package/site-templates/designs/minimalism/sample/public/logo-placeholder-2.svg +0 -6
  187. package/site-templates/designs/minimalism/sample/public/logo-placeholder-3.svg +0 -6
  188. 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 { lowerRole } from "@working-theory/validation";
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 role is then clamped to the
216
- * agent's ceiling (`lowerRole`), so the operator's agent acts as a member.
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(input: RouteLeadInput): Promise<PersistResult> {
219
+ async function persistToDb(
220
+ input: RouteLeadInput,
221
+ idempotencyKey?: string,
222
+ ): Promise<PersistResult> {
133
223
  if (input.kind === "submit") {
134
- const row = (await prisma.lead.create({
135
- data: {
136
- siteId: input.siteId,
137
- // #6203/#6254 — the isolation key, equal to `siteId` by
138
- // construction. `input.siteId` is the RESOLVED workspace id at
139
- // EVERY `routeLead(kind: "submit")` caller now — the primary route
140
- // (`routes/leads/index.ts`, never reads it from the body — see that
141
- // file's header) and the three service-layer callers
142
- // (`flow-engine.ts`'s `completeSubmission`,
143
- // `recommender-capture.ts`'s `captureRecommenderChoice`,
144
- // `project-listings.ts`'s `submitProjectListingDraft`) all resolve
145
- // the site alias before calling in (#6254 AC-6/AC-10). `workspaceId`
146
- // is NOT NULL as of
147
- // database/migrations/20260922120000_tenancy_fence, so this can no
148
- // longer stamp `null` for an unresolved alias the way it once did
149
- // (spec D2's old rule) — a caller that somehow still passed one
150
- // would instead hit the pre-existing `<table>_workspace_id_format`
151
- // CHECK loudly, via the AC-11 re-raise below, rather than landing a
152
- // silently-unfenced row.
153
- workspaceId: input.siteId,
154
- source: input.source,
155
- email: input.email,
156
- payload: input.payload as Prisma.InputJsonValue,
157
- },
158
- select: { id: true },
159
- })) as DbRow;
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 initMcpPublicWorkspace()", () => {
20
- expect(src).toMatch(/await initMcpPublicWorkspace\(\)/);
19
+ it("awaits initPublicWorkspace()", () => {
20
+ expect(src).toMatch(/await initPublicWorkspace\(\)/);
21
21
  });
22
22
 
23
- it("awaits initMcpPublicWorkspace() before either transport branch", () => {
24
- const initIdx = src.indexOf("await initMcpPublicWorkspace()");
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 initMcpPublicWorkspace()");
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.29.0",
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 { registerLicenseKeyStore } from "./lib/license-key-store.js";
29
- import { initMcpPublicWorkspace } from "./lib/public-workspace.js";
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 initMcpPublicWorkspace();
47
+ await initPublicWorkspace();
46
48
  } catch (err) {
47
49
  console.error(
48
- "[mcp] initMcpPublicWorkspace() failed — content tools will serve their " +
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 { mcpPublicWorkspace } from "./public-workspace.js";
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 = mcpPublicWorkspace();
70
+ const workspace = publicWorkspace();
71
71
  if (workspace.kind !== "resolved") {
72
72
  return emptyContentAdapter();
73
73
  }