@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.
- package/.turbo/turbo-build.log +45 -1
- package/build.mjs +25 -15
- package/dist/agentic/guides/module-facts.json +3375 -349
- package/dist/agentic/guides/modules/ai_assistant.md +57 -0
- package/dist/agentic/guides/modules/api_docs.md +39 -0
- package/dist/agentic/guides/modules/api_keys.md +41 -0
- package/dist/agentic/guides/modules/attachments.md +46 -0
- package/dist/agentic/guides/modules/audit_logs.md +42 -0
- package/dist/agentic/guides/modules/auth.md +1 -1
- package/dist/agentic/guides/modules/business_rules.md +44 -0
- package/dist/agentic/guides/modules/catalog.md +1 -1
- package/dist/agentic/guides/modules/channel_gmail.md +39 -0
- package/dist/agentic/guides/modules/channel_imap.md +39 -0
- package/dist/agentic/guides/modules/checkout.md +60 -0
- package/dist/agentic/guides/modules/communication_channels.md +65 -0
- package/dist/agentic/guides/modules/configs.md +42 -0
- package/dist/agentic/guides/modules/content.md +39 -0
- package/dist/agentic/guides/modules/currencies.md +1 -1
- package/dist/agentic/guides/modules/customer_accounts.md +1 -1
- package/dist/agentic/guides/modules/customers.md +1 -1
- package/dist/agentic/guides/modules/dashboards.md +43 -0
- package/dist/agentic/guides/modules/data_sync.md +1 -1
- package/dist/agentic/guides/modules/dictionaries.md +46 -0
- package/dist/agentic/guides/modules/directory.md +49 -0
- package/dist/agentic/guides/modules/entities.md +46 -0
- package/dist/agentic/guides/modules/events.md +39 -0
- package/dist/agentic/guides/modules/feature_toggles.md +42 -0
- package/dist/agentic/guides/modules/gateway_stripe.md +39 -0
- package/dist/agentic/guides/modules/generators.md +39 -0
- package/dist/agentic/guides/modules/inbox_ops.md +59 -0
- package/dist/agentic/guides/modules/integrations.md +1 -1
- package/dist/agentic/guides/modules/messages.md +55 -0
- package/dist/agentic/guides/modules/notifications.md +41 -0
- package/dist/agentic/guides/modules/onboarding.md +41 -0
- package/dist/agentic/guides/modules/payment_gateways.md +52 -0
- package/dist/agentic/guides/modules/perspectives.md +42 -0
- package/dist/agentic/guides/modules/planner.md +49 -0
- package/dist/agentic/guides/modules/portal.md +39 -0
- package/dist/agentic/guides/modules/progress.md +48 -0
- package/dist/agentic/guides/modules/query_index.md +46 -0
- package/dist/agentic/guides/modules/record_locks.md +53 -0
- package/dist/agentic/guides/modules/resources.md +62 -0
- package/dist/agentic/guides/modules/sales.md +1 -1
- package/dist/agentic/guides/modules/scheduler.md +46 -0
- package/dist/agentic/guides/modules/search.md +39 -0
- package/dist/agentic/guides/modules/security.md +67 -0
- package/dist/agentic/guides/modules/shipping_carriers.md +51 -0
- package/dist/agentic/guides/modules/sso.md +59 -0
- package/dist/agentic/guides/modules/staff.md +88 -0
- package/dist/agentic/guides/modules/storage_s3.md +39 -0
- package/dist/agentic/guides/modules/sync_akeneo.md +39 -0
- package/dist/agentic/guides/modules/sync_excel.md +41 -0
- package/dist/agentic/guides/modules/system_status_overlays.md +39 -0
- package/dist/agentic/guides/modules/translations.md +44 -0
- package/dist/agentic/guides/modules/webhooks.md +54 -0
- package/dist/agentic/guides/modules/workflows.md +1 -1
- package/dist/lib/generators/index.js +0 -2
- package/dist/lib/generators/index.js.map +2 -2
- package/dist/lib/generators/module-facts-discovery.js +46 -0
- package/dist/lib/generators/module-facts-discovery.js.map +7 -0
- package/dist/lib/generators/module-facts.js +14 -8
- package/dist/lib/generators/module-facts.js.map +2 -2
- package/dist/lib/testing/integration.js +185 -5
- package/dist/lib/testing/integration.js.map +2 -2
- package/dist/mercato.js +1 -3
- package/dist/mercato.js.map +2 -2
- package/package.json +5 -5
- package/src/__tests__/mercato.test.ts +0 -5
- package/src/lib/generators/__tests__/module-facts.bc-guard.test.ts +36 -23
- package/src/lib/generators/__tests__/module-facts.discovery.test.ts +88 -0
- package/src/lib/generators/index.ts +0 -1
- package/src/lib/generators/module-facts-discovery.ts +58 -0
- package/src/lib/generators/module-facts.ts +50 -10
- package/src/lib/testing/__tests__/integration.test.ts +228 -6
- package/src/lib/testing/integration.ts +231 -3
- package/src/mercato.ts +0 -2
- package/dist/lib/generators/module-facts-generate.js +0 -52
- package/dist/lib/generators/module-facts-generate.js.map +0 -7
- 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 =
|
|
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
|
|
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 {
|
|
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
|
|
675
|
-
// authenticated
|
|
676
|
-
|
|
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
|
-
}
|