@open-mercato/cli 0.6.6-develop.6458.1.113a54fb91 → 0.6.6-develop.6465.1.019f0fb26f

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 (79) hide show
  1. package/.turbo/turbo-build.log +45 -1
  2. package/build.mjs +25 -15
  3. package/dist/agentic/guides/module-facts.json +3375 -349
  4. package/dist/agentic/guides/modules/ai_assistant.md +57 -0
  5. package/dist/agentic/guides/modules/api_docs.md +39 -0
  6. package/dist/agentic/guides/modules/api_keys.md +41 -0
  7. package/dist/agentic/guides/modules/attachments.md +46 -0
  8. package/dist/agentic/guides/modules/audit_logs.md +42 -0
  9. package/dist/agentic/guides/modules/auth.md +1 -1
  10. package/dist/agentic/guides/modules/business_rules.md +44 -0
  11. package/dist/agentic/guides/modules/catalog.md +1 -1
  12. package/dist/agentic/guides/modules/channel_gmail.md +39 -0
  13. package/dist/agentic/guides/modules/channel_imap.md +39 -0
  14. package/dist/agentic/guides/modules/checkout.md +60 -0
  15. package/dist/agentic/guides/modules/communication_channels.md +65 -0
  16. package/dist/agentic/guides/modules/configs.md +42 -0
  17. package/dist/agentic/guides/modules/content.md +39 -0
  18. package/dist/agentic/guides/modules/currencies.md +1 -1
  19. package/dist/agentic/guides/modules/customer_accounts.md +1 -1
  20. package/dist/agentic/guides/modules/customers.md +1 -1
  21. package/dist/agentic/guides/modules/dashboards.md +43 -0
  22. package/dist/agentic/guides/modules/data_sync.md +1 -1
  23. package/dist/agentic/guides/modules/dictionaries.md +46 -0
  24. package/dist/agentic/guides/modules/directory.md +49 -0
  25. package/dist/agentic/guides/modules/entities.md +46 -0
  26. package/dist/agentic/guides/modules/events.md +39 -0
  27. package/dist/agentic/guides/modules/feature_toggles.md +42 -0
  28. package/dist/agentic/guides/modules/gateway_stripe.md +39 -0
  29. package/dist/agentic/guides/modules/generators.md +39 -0
  30. package/dist/agentic/guides/modules/inbox_ops.md +59 -0
  31. package/dist/agentic/guides/modules/integrations.md +1 -1
  32. package/dist/agentic/guides/modules/messages.md +55 -0
  33. package/dist/agentic/guides/modules/notifications.md +41 -0
  34. package/dist/agentic/guides/modules/onboarding.md +41 -0
  35. package/dist/agentic/guides/modules/payment_gateways.md +52 -0
  36. package/dist/agentic/guides/modules/perspectives.md +42 -0
  37. package/dist/agentic/guides/modules/planner.md +49 -0
  38. package/dist/agentic/guides/modules/portal.md +39 -0
  39. package/dist/agentic/guides/modules/progress.md +48 -0
  40. package/dist/agentic/guides/modules/query_index.md +46 -0
  41. package/dist/agentic/guides/modules/record_locks.md +53 -0
  42. package/dist/agentic/guides/modules/resources.md +62 -0
  43. package/dist/agentic/guides/modules/sales.md +1 -1
  44. package/dist/agentic/guides/modules/scheduler.md +46 -0
  45. package/dist/agentic/guides/modules/search.md +39 -0
  46. package/dist/agentic/guides/modules/security.md +67 -0
  47. package/dist/agentic/guides/modules/shipping_carriers.md +51 -0
  48. package/dist/agentic/guides/modules/sso.md +59 -0
  49. package/dist/agentic/guides/modules/staff.md +88 -0
  50. package/dist/agentic/guides/modules/storage_s3.md +39 -0
  51. package/dist/agentic/guides/modules/sync_akeneo.md +39 -0
  52. package/dist/agentic/guides/modules/sync_excel.md +41 -0
  53. package/dist/agentic/guides/modules/system_status_overlays.md +39 -0
  54. package/dist/agentic/guides/modules/translations.md +44 -0
  55. package/dist/agentic/guides/modules/webhooks.md +54 -0
  56. package/dist/agentic/guides/modules/workflows.md +1 -1
  57. package/dist/lib/generators/index.js +0 -2
  58. package/dist/lib/generators/index.js.map +2 -2
  59. package/dist/lib/generators/module-facts-discovery.js +46 -0
  60. package/dist/lib/generators/module-facts-discovery.js.map +7 -0
  61. package/dist/lib/generators/module-facts.js +14 -8
  62. package/dist/lib/generators/module-facts.js.map +2 -2
  63. package/dist/lib/testing/integration.js +185 -5
  64. package/dist/lib/testing/integration.js.map +2 -2
  65. package/dist/mercato.js +1 -3
  66. package/dist/mercato.js.map +2 -2
  67. package/package.json +5 -5
  68. package/src/__tests__/mercato.test.ts +0 -5
  69. package/src/lib/generators/__tests__/module-facts.bc-guard.test.ts +36 -23
  70. package/src/lib/generators/__tests__/module-facts.discovery.test.ts +88 -0
  71. package/src/lib/generators/index.ts +0 -1
  72. package/src/lib/generators/module-facts-discovery.ts +58 -0
  73. package/src/lib/generators/module-facts.ts +50 -10
  74. package/src/lib/testing/__tests__/integration.test.ts +228 -6
  75. package/src/lib/testing/integration.ts +231 -3
  76. package/src/mercato.ts +0 -2
  77. package/dist/lib/generators/module-facts-generate.js +0 -52
  78. package/dist/lib/generators/module-facts-generate.js.map +0 -7
  79. package/src/lib/generators/module-facts-generate.ts +0 -68
@@ -33,6 +33,11 @@ const projectRootDirectory = resolver.getRootDir()
33
33
  const appDirectory = path.join(projectRootDirectory, 'apps', 'mercato')
34
34
  const defaultPrivateAttachmentsRoot = path.join(appDirectory, 'storage', 'attachments', 'privateAttachments')
35
35
 
36
+ const makeSetCookieHeaders = (cookies: string[]): Headers => ({
37
+ get: (name: string) => (name.toLowerCase() === 'set-cookie' ? cookies.join(', ') : null),
38
+ getSetCookie: () => cookies,
39
+ }) as unknown as Headers
40
+
36
41
  const mockHealthyReadinessFetch = (
37
42
  overrides: {
38
43
  loginPageResponse?: { status: number; text?: string }
@@ -45,6 +50,10 @@ const mockHealthyReadinessFetch = (
45
50
  return {
46
51
  status: 200,
47
52
  ok: true,
53
+ headers: makeSetCookieHeaders([
54
+ 'auth_token=test-auth-token; Path=/; HttpOnly; SameSite=Lax',
55
+ 'session_token=test-session-token; Path=/; HttpOnly; SameSite=Lax',
56
+ ]),
48
57
  text: async () => JSON.stringify({ token: 'test-admin-token' }),
49
58
  } as unknown as Response
50
59
  }
@@ -68,6 +77,9 @@ const mockHealthyReadinessFetch = (
68
77
  text: async () => '<!doctype html><script src="/_next/static/chunks/app-healthcheck.js"></script>',
69
78
  } as unknown as Response
70
79
  }
80
+ if (url.endsWith('/backend')) {
81
+ return { status: 200, ok: true, text: async () => '' } as unknown as Response
82
+ }
71
83
  if (url.includes('/_next/static/chunks/app-healthcheck.js')) {
72
84
  return { status: 200, ok: true, text: async () => '' } as unknown as Response
73
85
  }
@@ -515,7 +527,7 @@ describe('integration cache and options', () => {
515
527
  ).resolves.toBe(false)
516
528
 
517
529
  await writeFile(sourceFile, 'const value = 1')
518
- await writeFile(sourceFile, 'const value = 2')
530
+ await writeFile(sourceFile, 'const value = 22')
519
531
  await expect(
520
532
  shouldReuseBuildArtifacts(120, 'integration', {
521
533
  inputPaths: [sourceFile],
@@ -628,6 +640,204 @@ describe('waitForApplicationReadiness', () => {
628
640
  const makeFakeProcess = (): ChildProcess => new EventEmitter() as unknown as ChildProcess
629
641
  const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
630
642
 
643
+ it('verifies backend browser navigation with cookies returned by login', async () => {
644
+ let backendCookieHeader: string | null = null
645
+
646
+ const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(async (input, init) => {
647
+ const url = typeof input === 'string' ? input : String(input)
648
+ const body = typeof init?.body === 'string' ? init.body : ''
649
+
650
+ if (url.endsWith('/api/auth/login')) {
651
+ if (body.includes('email=admin%40acme.com')) {
652
+ return {
653
+ status: 200,
654
+ ok: true,
655
+ headers: makeSetCookieHeaders([
656
+ 'auth_token=secret-auth-value; Path=/; HttpOnly; SameSite=Lax',
657
+ 'session_token=secret-session-value; Path=/; HttpOnly; SameSite=Lax',
658
+ ]),
659
+ text: async () => JSON.stringify({ token: 'token' }),
660
+ } as unknown as Response
661
+ }
662
+ return { status: 401, ok: false, text: async () => '' } as unknown as Response
663
+ }
664
+ if (url.endsWith('/login')) {
665
+ return {
666
+ status: 200,
667
+ ok: true,
668
+ text: async () => '<!doctype html><script src="/_next/static/chunks/app.js"></script>',
669
+ } as unknown as Response
670
+ }
671
+ if (url.includes('/api/customers/people')) {
672
+ return { status: 200, ok: true, text: async () => JSON.stringify({ items: [] }) } as unknown as Response
673
+ }
674
+ if (url.endsWith('/backend')) {
675
+ backendCookieHeader = typeof init?.headers === 'object'
676
+ && init.headers !== null
677
+ && !Array.isArray(init.headers)
678
+ ? String((init.headers as Record<string, unknown>).Cookie ?? '')
679
+ : ''
680
+ return { status: backendCookieHeader ? 200 : 307, ok: Boolean(backendCookieHeader), text: async () => '' } as unknown as Response
681
+ }
682
+ return { status: 200, ok: true, text: async () => '' } as unknown as Response
683
+ })
684
+
685
+ try {
686
+ await waitForApplicationReadiness('http://127.0.0.1:5001', makeFakeProcess(), {
687
+ timeoutMs: 1_000,
688
+ intervalMs: 5,
689
+ stabilizationMs: 10,
690
+ })
691
+ expect(backendCookieHeader).toContain('auth_token=secret-auth-value')
692
+ expect(backendCookieHeader).toContain('session_token=secret-session-value')
693
+ } finally {
694
+ fetchSpy.mockRestore()
695
+ }
696
+ })
697
+
698
+ it('reports backend auth redirect loops without leaking cookie values', async () => {
699
+ const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(async (input, init) => {
700
+ const url = typeof input === 'string' ? input : String(input)
701
+ const body = typeof init?.body === 'string' ? init.body : ''
702
+
703
+ if (url.endsWith('/api/auth/login')) {
704
+ if (body.includes('email=admin%40acme.com')) {
705
+ return {
706
+ status: 200,
707
+ ok: true,
708
+ headers: makeSetCookieHeaders([
709
+ 'auth_token=secret-auth-value; Path=/; HttpOnly; SameSite=Lax',
710
+ 'session_token=secret-session-value; Path=/; HttpOnly; SameSite=Lax',
711
+ ]),
712
+ text: async () => JSON.stringify({ token: 'token' }),
713
+ } as unknown as Response
714
+ }
715
+ return { status: 401, ok: false, text: async () => '' } as unknown as Response
716
+ }
717
+ if (url.endsWith('/login')) {
718
+ return {
719
+ status: 200,
720
+ ok: true,
721
+ text: async () => '<!doctype html><script src="/_next/static/chunks/app.js"></script>',
722
+ } as unknown as Response
723
+ }
724
+ if (url.includes('/api/customers/people')) {
725
+ return { status: 200, ok: true, text: async () => JSON.stringify({ items: [] }) } as unknown as Response
726
+ }
727
+ if (url.endsWith('/backend')) {
728
+ return {
729
+ status: 307,
730
+ ok: false,
731
+ headers: {
732
+ get: (name: string) => (name.toLowerCase() === 'location' ? '/api/auth/session/refresh' : null),
733
+ getSetCookie: () => [],
734
+ },
735
+ text: async () => '',
736
+ } as unknown as Response
737
+ }
738
+ if (url.endsWith('/api/auth/session/refresh')) {
739
+ return {
740
+ status: 307,
741
+ ok: false,
742
+ headers: {
743
+ get: (name: string) => (name.toLowerCase() === 'location' ? '/backend' : null),
744
+ getSetCookie: () => ['auth_token=rotated-secret-value; Path=/; HttpOnly; SameSite=Lax'],
745
+ },
746
+ text: async () => '',
747
+ } as unknown as Response
748
+ }
749
+ return { status: 200, ok: true, text: async () => '' } as unknown as Response
750
+ })
751
+
752
+ try {
753
+ let error: Error | null = null
754
+ try {
755
+ await waitForApplicationReadiness('http://127.0.0.1:5001', makeFakeProcess(), {
756
+ timeoutMs: 50,
757
+ intervalMs: 5,
758
+ stabilizationMs: 10,
759
+ })
760
+ } catch (caught) {
761
+ error = caught instanceof Error ? caught : new Error(String(caught))
762
+ }
763
+
764
+ expect(error).not.toBeNull()
765
+ expect(error?.message).toMatch(/Backend browser auth probe detected redirect loop/)
766
+ expect(error?.message).not.toMatch(/secret-(auth|session)|rotated-secret/)
767
+ } finally {
768
+ fetchSpy.mockRestore()
769
+ }
770
+ })
771
+
772
+ it('rejects unsafe backend auth redirects without following them', async () => {
773
+ let externalRedirectFetches = 0
774
+ const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(async (input, init) => {
775
+ const url = typeof input === 'string' ? input : String(input)
776
+ const body = typeof init?.body === 'string' ? init.body : ''
777
+
778
+ if (url.includes('evil.example')) {
779
+ externalRedirectFetches += 1
780
+ return { status: 200, ok: true, text: async () => '' } as unknown as Response
781
+ }
782
+ if (url.endsWith('/api/auth/login')) {
783
+ if (body.includes('email=admin%40acme.com')) {
784
+ return {
785
+ status: 200,
786
+ ok: true,
787
+ headers: makeSetCookieHeaders([
788
+ 'auth_token=secret-auth-value; Path=/; HttpOnly; SameSite=Lax',
789
+ 'session_token=secret-session-value; Path=/; HttpOnly; SameSite=Lax',
790
+ ]),
791
+ text: async () => JSON.stringify({ token: 'token' }),
792
+ } as unknown as Response
793
+ }
794
+ return { status: 401, ok: false, text: async () => '' } as unknown as Response
795
+ }
796
+ if (url.endsWith('/login')) {
797
+ return {
798
+ status: 200,
799
+ ok: true,
800
+ text: async () => '<!doctype html><script src="/_next/static/chunks/app.js"></script>',
801
+ } as unknown as Response
802
+ }
803
+ if (url.includes('/api/customers/people')) {
804
+ return { status: 200, ok: true, text: async () => JSON.stringify({ items: [] }) } as unknown as Response
805
+ }
806
+ if (url.endsWith('/backend')) {
807
+ return {
808
+ status: 307,
809
+ ok: false,
810
+ headers: {
811
+ get: (name: string) => (name.toLowerCase() === 'location' ? '//evil.example/session' : null),
812
+ getSetCookie: () => [],
813
+ },
814
+ text: async () => '',
815
+ } as unknown as Response
816
+ }
817
+ return { status: 200, ok: true, text: async () => '' } as unknown as Response
818
+ })
819
+
820
+ try {
821
+ let error: Error | null = null
822
+ try {
823
+ await waitForApplicationReadiness('http://127.0.0.1:5001', makeFakeProcess(), {
824
+ timeoutMs: 50,
825
+ intervalMs: 5,
826
+ stabilizationMs: 10,
827
+ })
828
+ } catch (caught) {
829
+ error = caught instanceof Error ? caught : new Error(String(caught))
830
+ }
831
+
832
+ expect(error).not.toBeNull()
833
+ expect(error?.message).toMatch(/unsafe redirect: protocol-relative redirect/)
834
+ expect(error?.message).not.toMatch(/secret-(auth|session)/)
835
+ expect(externalRedirectFetches).toBe(0)
836
+ } finally {
837
+ fetchSpy.mockRestore()
838
+ }
839
+ })
840
+
631
841
  it('serializes probe cycles so slow probes never pile up concurrent login attempts', async () => {
632
842
  let inFlight = 0
633
843
  let maxInFlight = 0
@@ -639,7 +849,7 @@ describe('waitForApplicationReadiness', () => {
639
849
  maxInFlight = Math.max(maxInFlight, inFlight)
640
850
  try {
641
851
  // Each probe fetch is slower than the retry interval; the old race-against-a-tick loop
642
- // would launch overlapping cycles here and blow past 3 concurrent in-flight requests.
852
+ // would launch overlapping cycles here and blow past one cycle's concurrent requests.
643
853
  await sleep(40)
644
854
  const isLoginPage = url.endsWith('/login') && !url.endsWith('/api/auth/login')
645
855
  if (isLoginPage) {
@@ -654,11 +864,22 @@ describe('waitForApplicationReadiness', () => {
654
864
  } as unknown as Response
655
865
  }
656
866
  if (url.endsWith('/api/auth/login')) {
657
- return { status: 200, ok: true, text: async () => JSON.stringify({ token: 'token' }) } as unknown as Response
867
+ return {
868
+ status: 200,
869
+ ok: true,
870
+ headers: makeSetCookieHeaders([
871
+ 'auth_token=test-auth-token; Path=/; HttpOnly; SameSite=Lax',
872
+ 'session_token=test-session-token; Path=/; HttpOnly; SameSite=Lax',
873
+ ]),
874
+ text: async () => JSON.stringify({ token: 'token' }),
875
+ } as unknown as Response
658
876
  }
659
877
  if (url.includes('/api/customers/people')) {
660
878
  return { status: 200, ok: true, text: async () => JSON.stringify({ items: [] }) } as unknown as Response
661
879
  }
880
+ if (url.endsWith('/backend')) {
881
+ return { status: 200, ok: true, text: async () => '' } as unknown as Response
882
+ }
662
883
  return { status: 200, ok: true, text: async () => '' } as unknown as Response
663
884
  } finally {
664
885
  inFlight -= 1
@@ -671,9 +892,10 @@ describe('waitForApplicationReadiness', () => {
671
892
  intervalMs: 5,
672
893
  stabilizationMs: 10,
673
894
  })
674
- // One cycle issues exactly three parallel probe fetches (login page, backend login,
675
- // authenticated login). Serialized cycles keep the peak at three; overlap would exceed it.
676
- expect(maxInFlight).toBeLessThanOrEqual(3)
895
+ // One cycle issues exactly four parallel probe fetches (login page, backend login,
896
+ // authenticated API, and backend browser-cookie navigation). Serialized cycles keep the
897
+ // peak at four; overlap would exceed it.
898
+ expect(maxInFlight).toBeLessThanOrEqual(4)
677
899
  expect(loginPageCycles).toBeGreaterThanOrEqual(3)
678
900
  } finally {
679
901
  fetchSpy.mockRestore()
@@ -318,6 +318,7 @@ const FOLDER_TO_CATEGORY_CODE: Record<string, string> = {
318
318
  api: 'API',
319
319
  integration: 'INT',
320
320
  }
321
+ const BACKEND_BROWSER_AUTH_REDIRECT_LIMIT = 6
321
322
  const BUILD_CACHE_STATE_VERSION = 2
322
323
  const BUILD_CACHE_ENV_KEYS = [
323
324
  'NODE_ENV',
@@ -393,11 +394,19 @@ type AuthenticatedApiProbeResult = {
393
394
  detail: string
394
395
  }
395
396
 
397
+ type BackendBrowserAuthProbeResult = {
398
+ loginStatus: number | null
399
+ backendStatus: number | null
400
+ healthy: boolean
401
+ detail: string
402
+ }
403
+
396
404
  type ApplicationReadinessProbeResult = {
397
405
  ready: boolean
398
406
  frontend: LoginPageProbeResult
399
407
  backend: BackendLoginProbeResult
400
408
  authenticated: AuthenticatedApiProbeResult
409
+ backendBrowserAuth: BackendBrowserAuthProbeResult
401
410
  }
402
411
 
403
412
  type PlaywrightFailureHealthCheckOptions = {
@@ -1473,18 +1482,236 @@ async function probeAuthenticatedApi(baseUrl: string): Promise<AuthenticatedApiP
1473
1482
  }
1474
1483
  }
1475
1484
 
1485
+ type HeadersWithSetCookie = Headers & {
1486
+ getSetCookie?: () => string[]
1487
+ }
1488
+
1489
+ function splitSetCookieHeader(header: string): string[] {
1490
+ return header
1491
+ .split(/,(?=\s*[^;,\s=]+=)/)
1492
+ .map((value) => value.trim())
1493
+ .filter((value) => value.length > 0)
1494
+ }
1495
+
1496
+ function getSetCookieHeaders(headers: Headers | undefined): string[] {
1497
+ if (!headers) {
1498
+ return []
1499
+ }
1500
+
1501
+ const setCookieGetter = (headers as HeadersWithSetCookie).getSetCookie
1502
+ if (typeof setCookieGetter === 'function') {
1503
+ return setCookieGetter.call(headers)
1504
+ .map((value) => value.trim())
1505
+ .filter((value) => value.length > 0)
1506
+ }
1507
+
1508
+ const combined = headers.get('set-cookie')
1509
+ return combined ? splitSetCookieHeader(combined) : []
1510
+ }
1511
+
1512
+ function parseSetCookiePair(header: string): { name: string; value: string } | null {
1513
+ const pair = header.split(';', 1)[0]?.trim()
1514
+ if (!pair) {
1515
+ return null
1516
+ }
1517
+ const equalsIndex = pair.indexOf('=')
1518
+ if (equalsIndex <= 0) {
1519
+ return null
1520
+ }
1521
+ const name = pair.slice(0, equalsIndex).trim()
1522
+ if (!name) {
1523
+ return null
1524
+ }
1525
+ return {
1526
+ name,
1527
+ value: pair.slice(equalsIndex + 1),
1528
+ }
1529
+ }
1530
+
1531
+ function addSetCookieHeadersToJar(jar: Map<string, string>, headers: Headers | undefined): void {
1532
+ for (const setCookieHeader of getSetCookieHeaders(headers)) {
1533
+ const pair = parseSetCookiePair(setCookieHeader)
1534
+ if (pair) {
1535
+ jar.set(pair.name, pair.value)
1536
+ }
1537
+ }
1538
+ }
1539
+
1540
+ function serializeCookieJar(jar: Map<string, string>): string {
1541
+ return [...jar.entries()].map(([name, value]) => `${name}=${value}`).join('; ')
1542
+ }
1543
+
1544
+ function formatCookieNames(jar: Map<string, string>): string {
1545
+ const names = [...jar.keys()].sort((left, right) => left.localeCompare(right))
1546
+ return names.length > 0 ? names.join(', ') : 'none'
1547
+ }
1548
+
1549
+ function toReadinessPath(url: URL): string {
1550
+ return url.pathname || '/'
1551
+ }
1552
+
1553
+ function resolveReadinessRedirect(
1554
+ baseUrl: URL,
1555
+ currentUrl: URL,
1556
+ rawLocation: string | null,
1557
+ ): { url: URL; path: string } | { error: string } {
1558
+ const location = rawLocation?.trim()
1559
+ if (!location) {
1560
+ return { error: 'missing Location header' }
1561
+ }
1562
+ if (location.startsWith('//')) {
1563
+ return { error: 'protocol-relative redirect' }
1564
+ }
1565
+
1566
+ let nextUrl: URL
1567
+ try {
1568
+ nextUrl = new URL(location, currentUrl)
1569
+ } catch {
1570
+ return { error: 'invalid Location header' }
1571
+ }
1572
+
1573
+ if (nextUrl.origin !== baseUrl.origin) {
1574
+ return { error: 'cross-origin redirect' }
1575
+ }
1576
+
1577
+ return {
1578
+ url: nextUrl,
1579
+ path: toReadinessPath(nextUrl),
1580
+ }
1581
+ }
1582
+
1583
+ async function probeBackendBrowserAuth(baseUrl: string): Promise<BackendBrowserAuthProbeResult> {
1584
+ try {
1585
+ const base = new URL(baseUrl)
1586
+ const form = new URLSearchParams()
1587
+ form.set('email', 'admin@acme.com')
1588
+ form.set('password', 'secret')
1589
+ const loginResponse = await probeFetch(`${baseUrl}/api/auth/login`, {
1590
+ method: 'POST',
1591
+ redirect: 'manual',
1592
+ headers: {
1593
+ 'Content-Type': 'application/x-www-form-urlencoded',
1594
+ },
1595
+ body: form.toString(),
1596
+ })
1597
+
1598
+ if (!loginResponse.ok) {
1599
+ return {
1600
+ loginStatus: loginResponse.status,
1601
+ backendStatus: null,
1602
+ healthy: false,
1603
+ detail: `Backend browser auth login returned ${loginResponse.status}`,
1604
+ }
1605
+ }
1606
+
1607
+ const cookieJar = new Map<string, string>()
1608
+ addSetCookieHeadersToJar(cookieJar, loginResponse.headers)
1609
+ if (cookieJar.size === 0) {
1610
+ return {
1611
+ loginStatus: loginResponse.status,
1612
+ backendStatus: null,
1613
+ healthy: false,
1614
+ detail: 'Backend browser auth login returned no cookies',
1615
+ }
1616
+ }
1617
+
1618
+ let currentUrl = new URL('/backend', base)
1619
+ let backendStatus: number | null = null
1620
+ const visitedPaths = new Set<string>()
1621
+ const trace: string[] = []
1622
+
1623
+ for (let redirectCount = 0; redirectCount <= BACKEND_BROWSER_AUTH_REDIRECT_LIMIT; redirectCount += 1) {
1624
+ const currentPath = toReadinessPath(currentUrl)
1625
+ visitedPaths.add(currentPath)
1626
+ const response = await probeFetch(currentUrl.toString(), {
1627
+ method: 'GET',
1628
+ redirect: 'manual',
1629
+ headers: {
1630
+ Cookie: serializeCookieJar(cookieJar),
1631
+ },
1632
+ })
1633
+ backendStatus = response.status
1634
+ addSetCookieHeadersToJar(cookieJar, response.headers)
1635
+
1636
+ const location = response.headers?.get('location') ?? null
1637
+ const traceRedirect = location ? resolveReadinessRedirect(base, currentUrl, location) : null
1638
+ const statusAndLocation = traceRedirect && !('error' in traceRedirect)
1639
+ ? `${currentPath} ${response.status} -> ${traceRedirect.path}`
1640
+ : `${currentPath} ${response.status}${location ? ' -> [unsafe]' : ''}`
1641
+ trace.push(statusAndLocation)
1642
+
1643
+ if (response.status === 200 && currentPath === '/backend') {
1644
+ return {
1645
+ loginStatus: loginResponse.status,
1646
+ backendStatus,
1647
+ healthy: true,
1648
+ detail: `Cookie-backed GET /backend returned 200 (cookies: ${formatCookieNames(cookieJar)})`,
1649
+ }
1650
+ }
1651
+
1652
+ if (response.status < 300 || response.status >= 400) {
1653
+ return {
1654
+ loginStatus: loginResponse.status,
1655
+ backendStatus,
1656
+ healthy: false,
1657
+ detail: `Cookie-backed GET ${currentPath} returned ${response.status} (cookies: ${formatCookieNames(cookieJar)}; trace: ${trace.join(' | ')})`,
1658
+ }
1659
+ }
1660
+
1661
+ const redirect = traceRedirect ?? resolveReadinessRedirect(base, currentUrl, location)
1662
+ if ('error' in redirect) {
1663
+ return {
1664
+ loginStatus: loginResponse.status,
1665
+ backendStatus,
1666
+ healthy: false,
1667
+ detail: `Backend browser auth probe followed unsafe redirect: ${redirect.error} (cookies: ${formatCookieNames(cookieJar)}; trace: ${trace.join(' | ')})`,
1668
+ }
1669
+ }
1670
+
1671
+ if (visitedPaths.has(redirect.path)) {
1672
+ trace.push(redirect.path)
1673
+ return {
1674
+ loginStatus: loginResponse.status,
1675
+ backendStatus,
1676
+ healthy: false,
1677
+ detail: `Backend browser auth probe detected redirect loop: ${trace.join(' | ')} (cookies: ${formatCookieNames(cookieJar)})`,
1678
+ }
1679
+ }
1680
+
1681
+ currentUrl = redirect.url
1682
+ }
1683
+
1684
+ return {
1685
+ loginStatus: loginResponse.status,
1686
+ backendStatus,
1687
+ healthy: false,
1688
+ detail: `Backend browser auth probe exceeded ${BACKEND_BROWSER_AUTH_REDIRECT_LIMIT} redirects (cookies: ${formatCookieNames(cookieJar)}; trace: ${trace.join(' | ')})`,
1689
+ }
1690
+ } catch (error) {
1691
+ const message = error instanceof Error ? error.message : String(error)
1692
+ return {
1693
+ loginStatus: null,
1694
+ backendStatus: null,
1695
+ healthy: false,
1696
+ detail: `Backend browser auth probe failed: ${message}`,
1697
+ }
1698
+ }
1699
+ }
1700
+
1476
1701
  async function probeApplicationReadiness(baseUrl: string): Promise<ApplicationReadinessProbeResult> {
1477
- const [frontend, backend, authenticated] = await Promise.all([
1702
+ const [frontend, backend, authenticated, backendBrowserAuth] = await Promise.all([
1478
1703
  probeLoginPage(baseUrl),
1479
1704
  probeBackendLoginEndpoint(baseUrl),
1480
1705
  probeAuthenticatedApi(baseUrl),
1706
+ probeBackendBrowserAuth(baseUrl),
1481
1707
  ])
1482
1708
 
1483
1709
  return {
1484
- ready: frontend.healthy && backend.healthy && authenticated.healthy,
1710
+ ready: frontend.healthy && backend.healthy && authenticated.healthy && backendBrowserAuth.healthy,
1485
1711
  frontend,
1486
1712
  backend,
1487
1713
  authenticated,
1714
+ backendBrowserAuth,
1488
1715
  }
1489
1716
  }
1490
1717
 
@@ -1904,9 +2131,10 @@ export async function waitForApplicationReadiness(
1904
2131
  const lastFrontendDetail = lastProbe?.frontend.detail ?? 'GET /login was never observed'
1905
2132
  const lastBackendDetail = lastProbe?.backend.detail ?? 'POST /api/auth/login was never observed'
1906
2133
  const lastAuthenticatedDetail = lastProbe?.authenticated.detail ?? 'Authenticated API probe was never observed'
2134
+ const lastBackendBrowserAuthDetail = lastProbe?.backendBrowserAuth.detail ?? 'Backend browser auth probe was never observed'
1907
2135
  throw new Error(
1908
2136
  `Application did not become ready within ${options.timeoutMs / 1000} seconds. ` +
1909
- `Last probe: ${lastFrontendDetail}; ${lastBackendDetail}; ${lastAuthenticatedDetail}`,
2137
+ `Last probe: ${lastFrontendDetail}; ${lastBackendDetail}; ${lastAuthenticatedDetail}; ${lastBackendBrowserAuthDetail}`,
1910
2138
  )
1911
2139
  }
1912
2140
 
package/src/mercato.ts CHANGED
@@ -756,7 +756,6 @@ async function runGeneratorSuite(quiet: boolean): Promise<void> {
756
756
  generateModuleDi,
757
757
  generateModulePackageSources,
758
758
  generateOpenApi,
759
- generateModuleFacts,
760
759
  } = await import('./lib/generators')
761
760
  const resolver = createResolver()
762
761
  await generateEntityIds({ resolver, quiet })
@@ -767,7 +766,6 @@ async function runGeneratorSuite(quiet: boolean): Promise<void> {
767
766
  await generateModuleDi({ resolver, quiet })
768
767
  await generateModulePackageSources({ resolver, quiet })
769
768
  await generateOpenApi({ resolver, quiet })
770
- await generateModuleFacts({ resolver, quiet })
771
769
  }
772
770
 
773
771
  /**
@@ -1,52 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import {
4
- createGeneratorResult,
5
- ensureDir,
6
- logGenerationResult
7
- } from "../utils.js";
8
- import { extractAllModuleFacts, renderModuleFactsJson } from "./module-facts.js";
9
- function readCoreVersion(coreSrcRoot) {
10
- const corePackageJsonPath = path.resolve(coreSrcRoot, "..", "..", "package.json");
11
- if (!fs.existsSync(corePackageJsonPath)) return null;
12
- try {
13
- const parsed = JSON.parse(fs.readFileSync(corePackageJsonPath, "utf8"));
14
- return typeof parsed.version === "string" ? parsed.version : null;
15
- } catch {
16
- return null;
17
- }
18
- }
19
- async function generateModuleFacts(options) {
20
- const { resolver, quiet } = options;
21
- const result = createGeneratorResult();
22
- const rootDir = resolver.getRootDir();
23
- const coreSrcRoot = path.join(rootDir, "packages", "core", "src", "modules");
24
- if (!fs.existsSync(coreSrcRoot)) {
25
- if (!quiet) {
26
- console.warn(`[module-facts] core module sources not found at ${coreSrcRoot}; skipping module-facts generation`);
27
- }
28
- return result;
29
- }
30
- const registryPath = path.join(resolver.getOutputDir(), "modules.runtime.generated.ts");
31
- const coreVersion = readCoreVersion(coreSrcRoot);
32
- const { factsByModule, warnings } = extractAllModuleFacts({ coreSrcRoot, registryPath, coreVersion });
33
- for (const warning of warnings) {
34
- console.warn(warning);
35
- }
36
- const content = renderModuleFactsJson(factsByModule);
37
- const outFile = path.join(resolver.getAppDir(), "src", "module-facts.generated.json");
38
- const existing = fs.existsSync(outFile) ? fs.readFileSync(outFile, "utf8") : null;
39
- if (existing === content) {
40
- result.filesUnchanged.push(outFile);
41
- return result;
42
- }
43
- ensureDir(outFile);
44
- fs.writeFileSync(outFile, content);
45
- result.filesWritten.push(outFile);
46
- logGenerationResult(path.relative(process.cwd(), outFile), true);
47
- return result;
48
- }
49
- export {
50
- generateModuleFacts
51
- };
52
- //# sourceMappingURL=module-facts-generate.js.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../src/lib/generators/module-facts-generate.ts"],
4
- "sourcesContent": ["import fs from 'node:fs'\nimport path from 'node:path'\nimport type { PackageResolver } from '../resolver'\nimport {\n createGeneratorResult,\n ensureDir,\n logGenerationResult,\n type GeneratorResult,\n} from '../utils'\nimport { extractAllModuleFacts, renderModuleFactsJson } from './module-facts'\n\nexport interface ModuleFactsOptions {\n resolver: PackageResolver\n quiet?: boolean\n}\n\nfunction readCoreVersion(coreSrcRoot: string): string | null {\n const corePackageJsonPath = path.resolve(coreSrcRoot, '..', '..', 'package.json')\n if (!fs.existsSync(corePackageJsonPath)) return null\n try {\n const parsed = JSON.parse(fs.readFileSync(corePackageJsonPath, 'utf8')) as { version?: unknown }\n return typeof parsed.version === 'string' ? parsed.version : null\n } catch {\n return null\n }\n}\n\n/**\n * Emits the versioned `apps/mercato/src/module-facts.generated.json` artifact from\n * monorepo core module sources. Must run AFTER `generateModuleRegistry` because\n * per-route API auth is read from the generated `modules.runtime.generated.ts`\n * registry (`apis[].metadata`). Registry-derived warnings are non-fatal.\n */\nexport async function generateModuleFacts(options: ModuleFactsOptions): Promise<GeneratorResult> {\n const { resolver, quiet } = options\n const result = createGeneratorResult()\n\n const rootDir = resolver.getRootDir()\n const coreSrcRoot = path.join(rootDir, 'packages', 'core', 'src', 'modules')\n if (!fs.existsSync(coreSrcRoot)) {\n if (!quiet) {\n console.warn(`[module-facts] core module sources not found at ${coreSrcRoot}; skipping module-facts generation`)\n }\n return result\n }\n\n const registryPath = path.join(resolver.getOutputDir(), 'modules.runtime.generated.ts')\n const coreVersion = readCoreVersion(coreSrcRoot)\n\n const { factsByModule, warnings } = extractAllModuleFacts({ coreSrcRoot, registryPath, coreVersion })\n for (const warning of warnings) {\n console.warn(warning)\n }\n\n const content = renderModuleFactsJson(factsByModule)\n const outFile = path.join(resolver.getAppDir(), 'src', 'module-facts.generated.json')\n const existing = fs.existsSync(outFile) ? fs.readFileSync(outFile, 'utf8') : null\n if (existing === content) {\n result.filesUnchanged.push(outFile)\n return result\n }\n\n ensureDir(outFile)\n fs.writeFileSync(outFile, content)\n result.filesWritten.push(outFile)\n logGenerationResult(path.relative(process.cwd(), outFile), true)\n return result\n}\n"],
5
- "mappings": "AAAA,OAAO,QAAQ;AACf,OAAO,UAAU;AAEjB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AACP,SAAS,uBAAuB,6BAA6B;AAO7D,SAAS,gBAAgB,aAAoC;AAC3D,QAAM,sBAAsB,KAAK,QAAQ,aAAa,MAAM,MAAM,cAAc;AAChF,MAAI,CAAC,GAAG,WAAW,mBAAmB,EAAG,QAAO;AAChD,MAAI;AACF,UAAM,SAAS,KAAK,MAAM,GAAG,aAAa,qBAAqB,MAAM,CAAC;AACtE,WAAO,OAAO,OAAO,YAAY,WAAW,OAAO,UAAU;AAAA,EAC/D,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAQA,eAAsB,oBAAoB,SAAuD;AAC/F,QAAM,EAAE,UAAU,MAAM,IAAI;AAC5B,QAAM,SAAS,sBAAsB;AAErC,QAAM,UAAU,SAAS,WAAW;AACpC,QAAM,cAAc,KAAK,KAAK,SAAS,YAAY,QAAQ,OAAO,SAAS;AAC3E,MAAI,CAAC,GAAG,WAAW,WAAW,GAAG;AAC/B,QAAI,CAAC,OAAO;AACV,cAAQ,KAAK,mDAAmD,WAAW,oCAAoC;AAAA,IACjH;AACA,WAAO;AAAA,EACT;AAEA,QAAM,eAAe,KAAK,KAAK,SAAS,aAAa,GAAG,8BAA8B;AACtF,QAAM,cAAc,gBAAgB,WAAW;AAE/C,QAAM,EAAE,eAAe,SAAS,IAAI,sBAAsB,EAAE,aAAa,cAAc,YAAY,CAAC;AACpG,aAAW,WAAW,UAAU;AAC9B,YAAQ,KAAK,OAAO;AAAA,EACtB;AAEA,QAAM,UAAU,sBAAsB,aAAa;AACnD,QAAM,UAAU,KAAK,KAAK,SAAS,UAAU,GAAG,OAAO,6BAA6B;AACpF,QAAM,WAAW,GAAG,WAAW,OAAO,IAAI,GAAG,aAAa,SAAS,MAAM,IAAI;AAC7E,MAAI,aAAa,SAAS;AACxB,WAAO,eAAe,KAAK,OAAO;AAClC,WAAO;AAAA,EACT;AAEA,YAAU,OAAO;AACjB,KAAG,cAAc,SAAS,OAAO;AACjC,SAAO,aAAa,KAAK,OAAO;AAChC,sBAAoB,KAAK,SAAS,QAAQ,IAAI,GAAG,OAAO,GAAG,IAAI;AAC/D,SAAO;AACT;",
6
- "names": []
7
- }